diff --git a/.env b/.env
new file mode 100644
index 0000000..21068fa
--- /dev/null
+++ b/.env
@@ -0,0 +1,18 @@
+# 数据库配置
+DB_HOST = 127.0.0.1
+DB_PORT = 3306
+DB_NAME = opm_ectms_cz_20260227
+DB_USER = root
+DB_PASSWORD = user
+DB_DEBUG = false
+
+
+FLOW_PROCESS_CONFIG_KEY = standard
+FLOW_USE_CUSTOM_PROCESS = true
+
+# 机器ID,用于分布式环境,确保唯一。0 为使用 MAC 地址,HASH 散列两位
+MACHINE_ID = 0
+
+# TCP 服务器配置
+TCP_SERVER_PORT = 50000
+
diff --git a/webman/.gitignore b/.gitignore
similarity index 100%
rename from webman/.gitignore
rename to .gitignore
diff --git a/.idea/php.xml b/.idea/php.xml
index faac9f9..9e24c62 100644
--- a/.idea/php.xml
+++ b/.idea/php.xml
@@ -10,10 +10,143 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/tcpserver.iml b/.idea/tcpserver.iml
index c956989..627f5f1 100644
--- a/.idea/tcpserver.iml
+++ b/.idea/tcpserver.iml
@@ -1,7 +1,113 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results
new file mode 100644
index 0000000..bef561f
--- /dev/null
+++ b/.phpunit.cache/test-results
@@ -0,0 +1 @@
+{"version":2,"defects":{"tests\\flow\\FlowProcessorTest::testCreateProcessor":8,"tests\\flow\\FlowProcessorTest::testCreateProcessorWithConfig":8,"tests\\flow\\FlowProcessorTest::testUpdateConfig":8,"tests\\flow\\FlowProcessorTest::testBatchNoConsistency":8,"tests\\flow\\FlowProcessorTest::testBatchNoConsistencyInCompleteProcess":8,"tests\\flow\\FlowProcessorTest::testDatabaseOperationFlags":8,"tests\\flow\\FlowProcessorTest::testWebSocketNotifyFlag":8,"tests\\flow\\FlowProcessorTest::testVoiceGeneration":8,"tests\\flow\\FlowProcessorTest::testErrorVoiceGeneration":7,"tests\\flow\\FlowProcessorTest::testMultiHospitalConfig":8,"tests\\flow\\FlowProcessorTest::testSpecialHospitalConfig":8,"tests\\flow\\ProcessEngineTest::testTimeValidationFailure":8,"tests\\flow\\ProcessEngineTest::testWrongStep":7,"tests\\flow\\strategies\\TimeValidationStrategyTest::testCustomDuration":7,"tests\\flow\\ProcessEngineTest::testSkipStep":7,"tests\\flow\\BatchConsistencyTest::testBatchNoPreservedOnError":8,"tests\\flow\\BatchConsistencyTest::testBatchNoWithDifferentProcessTypes":8,"tests\\flow\\EdgeCaseTest::testBatchNoGenerationEdgeCases":7,"tests\\flow\\PerformanceTest::testContextCreationPerformance":7,"tests\\flow\\PerformanceTest::testStrategyExecutionPerformance":7,"tests\\flow\\PerformanceTest::testChainTraversalPerformance":8,"tests\\flow\\nodes\\MorningWashNodeTest::testCanHandleMorningWashReader":7,"tests\\flow\\nodes\\MorningWashNodeTest::testHandleProcess":7,"tests\\flow\\nodes\\MorningWashNodeTest::testGenerateBatchNo":8,"tests\\flow\\nodes\\MorningWashNodeTest::testCanHandleDisinfectReaderForMorningWash":8,"tests\\flow\\nodes\\MorningWashNodeTest::testCanHandleMachineWashReaderForMorningWash":8,"tests\\flow\\nodes\\MorningWashNodeTest::testHandleProcessWithDisinfect":8,"tests\\flow\\nodes\\MorningWashNodeTest::testHandleProcessWithMachineWash":8,"tests\\flow\\EdgeCaseTest::testConcurrentBatchNoGeneration":7,"tests\\flow\\ProcessEngineTest::testDisinfectAfterWash":7,"tests\\flow\\nodes\\DisinfectNodeTest::testCanHandleAfterWash":8,"tests\\flow\\nodes\\DryNodeTest::testCanHandleAfterDisinfect":8,"tests\\flow\\UsageExampleTest::testExample4CustomVoice":8,"tests\\flow\\UsageExampleTest::testExample8TimeValidation":8,"tests\\flow\\UsageExampleTest::testLoadCustomProcessFromConfig":8,"tests\\flow\\UsageExampleTest::testCreateNoMorningWashFromConfig":8,"tests\\flow\\UsageExampleTest::testCreateMachineWashFromConfig":8,"tests\\flow\\UsageExampleTest::testCreateYiwuModeFromConfig":8,"tests\\flow\\UsageExampleTest::testExample1StandardProcess":8,"tests\\flow\\UsageExampleTest::testExample2NoMorningWash":8,"tests\\flow\\UsageExampleTest::testExample3DynamicAdjust":8,"tests\\flow\\UsageExampleTest::testExample5MorningWashModes":8,"tests\\flow\\UsageExampleTest::testExample6MachineWash":8,"tests\\flow\\UsageExampleTest::testExample7SimpleProcess":8,"tests\\flow\\UsageExampleTest::testExample9FullProcess":8,"tests\\flow\\UsageExampleTest::testExample10MultiHospital":8,"tests\\flow\\UsageExampleTest::testFlowProcessorUsage":8,"tests\\flow\\UsageExampleTest::testFlowProcessorCompleteProcess":8,"tests\\flow\\UsageExampleTest::testFlowProcessorWithConfig":8,"tests\\flow\\BatchConsistencyTest::testSingleMachineBatchConsistency":8,"tests\\flow\\BatchConsistencyTest::testMultiEndoscopeBatchUniqueness":8,"tests\\flow\\BatchConsistencyTest::testBatchNoStability":8,"tests\\flow\\BatchConsistencyTest::testNewProcessGeneratesNewBatchNo":8,"tests\\flow\\BatchConsistencyTest::testDistributedBatchConsistency":8,"tests\\flow\\PerformanceTest::testSingleExecutionPerformance":8,"tests\\flow\\PerformanceTest::testCompleteProcessPerformance":8,"tests\\flow\\PerformanceTest::testEngineCreationPerformance":8,"tests\\flow\\PerformanceTest::testConcurrentProcessingSimulation":8,"tests\\flow\\ProcessEngineTest::testCreateStandardEngine":8,"tests\\flow\\ProcessEngineTest::testCreateNoMorningWashEngine":8,"tests\\flow\\ProcessEngineTest::testCreateSimpleEngine":8,"tests\\flow\\ProcessEngineTest::testCompleteWashProcess":8,"tests\\flow\\ProcessEngineTest::testStandardWashRinseDisinfectFlow":8,"tests\\flow\\ProcessEngineTest::testDisableNode":8,"tests\\flow\\ProcessEngineTest::testGetNodes":8,"tests\\flow\\ProcessEngineTest::testGetEnabledNodes":8,"tests\\flow\\ProcessEngineTest::testUpdateConfig":8,"tests\\flow\\ProcessEngineTest::testMachineWashProcess":8,"tests\\db\\DBC::testDBConnect":8,"tests\\db\\DBC::testGenTables":7,"tests\\db\\DBC::testModel":5,"tests\\flow\\nodes\\DisinfectNodeTest::testNodeIdentity":8,"tests\\flow\\nodes\\DisinfectNodeTest::testCanHandleAfterRinse":8,"tests\\flow\\nodes\\DisinfectNodeTest::testCanHandleAfterFinalRinse":8,"tests\\flow\\nodes\\DisinfectNodeTest::testCannotHandleNonDisinfectReader":8,"tests\\flow\\nodes\\DisinfectNodeTest::testCannotHandleAfterDisinfect":8,"tests\\flow\\nodes\\DisinfectNodeTest::testHandleProcess":8,"tests\\flow\\nodes\\DisinfectNodeTest::testDatabaseOperationFlags":7,"tests\\flow\\nodes\\DisinfectNodeTest::testWebSocketNotifyFlag":8,"tests\\flow\\nodes\\DryNodeTest::testNodeIdentity":8,"tests\\flow\\nodes\\DryNodeTest::testCanHandleAfterFinalRinse":7,"tests\\flow\\nodes\\DryNodeTest::testCannotHandleAfterRinse":8,"tests\\flow\\nodes\\DryNodeTest::testCannotHandleNonDryReader":8,"tests\\flow\\nodes\\DryNodeTest::testCannotHandleAfterDry":7,"tests\\flow\\nodes\\DryNodeTest::testHandleProcess":7,"tests\\flow\\nodes\\DryNodeTest::testDatabaseOperationFlags":7,"tests\\flow\\nodes\\DryNodeTest::testWebSocketNotifyFlag":7,"tests\\flow\\nodes\\DryNodeTest::testKeepExistingBatchNo":8,"tests\\flow\\nodes\\MorningWashNodeTest::testNodeIdentity":8,"tests\\flow\\nodes\\MorningWashNodeTest::testCannotHandleWhenNoNeed":8,"tests\\flow\\nodes\\MorningWashNodeTest::testCannotHandleNonDisinfectMachineReader":8,"tests\\flow\\nodes\\MorningWashNodeTest::testCannotHandleWhenAlreadyWashed":8,"tests\\flow\\nodes\\EndNodeTest::testNodeIdentity":8,"tests\\flow\\nodes\\EndNodeTest::testCanHandleAfterDry":8,"tests\\flow\\nodes\\EndNodeTest::testCanHandleAfterDisinfect":7,"tests\\flow\\nodes\\EndNodeTest::testCanHandleAfterFinalRinse":8,"tests\\flow\\nodes\\EndNodeTest::testCanHandleAfterMachineWash":8,"tests\\flow\\nodes\\EndNodeTest::testCannotHandleNonEndReader":8,"tests\\flow\\nodes\\EndNodeTest::testCannotHandleAfterWash":8,"tests\\flow\\nodes\\EndNodeTest::testCannotHandleAfterRinse":8,"tests\\flow\\nodes\\EndNodeTest::testCannotHandleWithEmptyStep":8,"tests\\flow\\nodes\\EndNodeTest::testHandleProcess":8,"tests\\flow\\nodes\\EndNodeTest::testDatabaseOperationIsUpdate":7,"tests\\flow\\nodes\\EndNodeTest::testWebSocketNotifyFlag":8,"tests\\flow\\nodes\\EndNodeTest::testActionEndTimeIsSet":8,"tests\\flow\\nodes\\FinalRinseNodeTest::testCanHandleAfterDisinfect":8,"tests\\flow\\nodes\\FinalRinseNodeTest::testCanHandleAfterMachineWashByDefault":8,"tests\\flow\\nodes\\FinalRinseNodeTest::testCannotHandleAfterMachineWashWhenDisabled":8,"tests\\flow\\nodes\\FinalRinseNodeTest::testCannotHandleNonFinalRinseReader":8,"tests\\flow\\nodes\\FinalRinseNodeTest::testHandleProcess":8,"tests\\flow\\nodes\\FinalRinseNodeTest::testCannotHandleAfterWash":8,"tests\\flow\\nodes\\FinalRinseNodeTest::testDatabaseOperationFlags":8,"tests\\flow\\nodes\\FinalRinseNodeTest::testWebSocketNotifyFlag":8,"tests\\flow\\nodes\\FinalRinseNodeTest::testNodeIdentity":8,"tests\\flow\\nodes\\MachineWashNodeTest::testNodeIdentity":8,"tests\\flow\\nodes\\MachineWashNodeTest::testCanHandleAfterWash":8,"tests\\flow\\nodes\\MachineWashNodeTest::testCanHandleAfterRinse":7,"tests\\flow\\nodes\\MachineWashNodeTest::testCanHandleAfterDisinfect":7,"tests\\flow\\nodes\\MachineWashNodeTest::testCanHandleWithEmptyStep":7,"tests\\flow\\nodes\\MachineWashNodeTest::testCanHandleAfterEnd":7,"tests\\flow\\nodes\\MachineWashNodeTest::testCanHandleAfterEndoscopeOut":8,"tests\\flow\\nodes\\MachineWashNodeTest::testCannotHandleNonMachineWashReader":8,"tests\\flow\\nodes\\MachineWashNodeTest::testCannotHandleAfterFinalRinse":8,"tests\\flow\\nodes\\MachineWashNodeTest::testCannotHandleAfterDry":8,"tests\\flow\\nodes\\MachineWashNodeTest::testHandleProcess":8,"tests\\flow\\nodes\\MachineWashNodeTest::testDatabaseOperationFlags":7,"tests\\flow\\nodes\\MachineWashNodeTest::testWebSocketNotifyFlag":8,"tests\\flow\\nodes\\RinseNodeTest::testNodeIdentity":8,"tests\\flow\\nodes\\RinseNodeTest::testCanHandleAfterWash":7,"tests\\flow\\nodes\\RinseNodeTest::testCannotHandleNonRinseReader":8,"tests\\flow\\nodes\\RinseNodeTest::testCannotHandleAfterDisinfect":7,"tests\\flow\\nodes\\RinseNodeTest::testCannotHandleWithEmptyStep":7,"tests\\flow\\nodes\\RinseNodeTest::testCannotHandleAfterRinse":7,"tests\\flow\\nodes\\RinseNodeTest::testHandleProcess":7,"tests\\flow\\nodes\\RinseNodeTest::testDatabaseOperationFlags":7,"tests\\flow\\nodes\\RinseNodeTest::testWebSocketNotifyFlag":7,"tests\\flow\\nodes\\RinseNodeTest::testDisabledNodeSkips":8,"tests\\flow\\nodes\\WashNodeTest::testNodeIdentity":8,"tests\\flow\\nodes\\WashNodeTest::testCanHandleWashReader":8,"tests\\flow\\nodes\\WashNodeTest::testCannotHandleNonWashReader":8,"tests\\flow\\nodes\\WashNodeTest::testCanStartNewWashProcess":8,"tests\\flow\\nodes\\WashNodeTest::testCannotWashWithoutMorningWash":8,"tests\\flow\\nodes\\WashNodeTest::testHandleProcess":8,"tests\\flow\\nodes\\WashNodeTest::testGenerateBatchNo":7,"tests\\flow\\nodes\\WashNodeTest::testKeepExistingBatchNo":8,"tests\\flow\\strategies\\TimeValidationStrategyTest::testTimeRequirementNotMet":7,"tests\\flow\\strategies\\TimeValidationStrategyTest::testSetStepDuration":7,"tests\\flow\\FlowProcessorTest::testSuccessfulFlowSavesToDatabase":8,"tests\\flow\\FlowProcessorTest::testEndOperationIsUpdate":8,"tests\\flow\\FlowProcessorTest::testGetActionTypeMapping":8,"tests\\flow\\EdgeCaseTest::testLongEndoscopeName":7,"tests\\flow\\EdgeCaseTest::testSpecialCharactersInName":7,"tests\\flow\\ProcessContextTest::testGetFullVoice":7,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testFullVoiceIncludesEndoscopeName":7,"tests\\flow\\FlowProcessorTest::testSuccessfulFlowSendsVoice":8,"tests\\flow\\FlowProcessorTest::testFailedFlowDoesNotSaveToDatabase":8,"tests\\flow\\FlowProcessorTest::testFailedFlowStillSendsVoice":8,"tests\\flow\\FlowProcessorTest::testWebSocketNotificationSentWhenNeeded":8,"tests\\flow\\FlowProcessorTest::testWebSocketNotificationNotSentWhenNotNeeded":8,"tests\\flow\\FlowProcessorTest::testStaticFactoryCreate":8,"tests\\flow\\EdgeCaseTest::testAllNodesDisabled":8,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testFinalRinseVoice":8},"times":{"tests\\flow\\FlowProcessorTest::testCreateProcessor":0.006,"tests\\flow\\FlowProcessorTest::testCreateProcessorWithConfig":0.006,"tests\\flow\\FlowProcessorTest::testStaticFactory":0.006,"tests\\flow\\FlowProcessorTest::testUpdateConfig":0.009,"tests\\flow\\FlowProcessorTest::testBatchNoConsistency":0.006,"tests\\flow\\FlowProcessorTest::testBatchNoConsistencyInCompleteProcess":0.008,"tests\\flow\\FlowProcessorTest::testDatabaseOperationFlags":0.006,"tests\\flow\\FlowProcessorTest::testWebSocketNotifyFlag":0.005,"tests\\flow\\FlowProcessorTest::testVoiceGeneration":0.007,"tests\\flow\\FlowProcessorTest::testErrorVoiceGeneration":0.006,"tests\\flow\\FlowProcessorTest::testMultiHospitalConfig":0.007,"tests\\flow\\FlowProcessorTest::testSpecialHospitalConfig":0.006,"tests\\flow\\ProcessContextTest::testCreateContext":0.005,"tests\\flow\\ProcessContextTest::testSetError":0.006,"tests\\flow\\ProcessContextTest::testSetVoice":0.006,"tests\\flow\\ProcessContextTest::testGetFullVoice":0.006,"tests\\flow\\ProcessContextTest::testStepTimeManagement":0.006,"tests\\flow\\ProcessContextTest::testStepDurationManagement":0.005,"tests\\flow\\ProcessContextTest::testDefaultStepDuration":0.007,"tests\\flow\\ProcessContextTest::testGenerateBatchNo":0.006,"tests\\flow\\ProcessContextTest::testBatchNoUniqueness":0.006,"tests\\flow\\ProcessContextTest::testCanStartNewProcess":0.006,"tests\\flow\\ProcessContextTest::testIsWashProcessCompleted":0.006,"tests\\flow\\ProcessContextTest::testChaining":0.005,"tests\\flow\\ProcessEngineTest::testCreateStandardEngine":0.006,"tests\\flow\\ProcessEngineTest::testCreateNoMorningWashEngine":0.006,"tests\\flow\\ProcessEngineTest::testCreateSimpleEngine":0.006,"tests\\flow\\ProcessEngineTest::testCompleteWashProcess":0.045,"tests\\flow\\ProcessEngineTest::testTimeValidationFailure":0.005,"tests\\flow\\ProcessEngineTest::testWrongStep":0.01,"tests\\flow\\ProcessEngineTest::testDisableNode":0.005,"tests\\flow\\ProcessEngineTest::testGetNodes":0.006,"tests\\flow\\ProcessEngineTest::testGetEnabledNodes":0.005,"tests\\flow\\ProcessEngineTest::testUpdateConfig":0.006,"tests\\flow\\ProcessEngineTest::testMachineWashProcess":0.033,"tests\\flow\\nodes\\FinalRinseNodeTest::testCanHandleAfterDisinfect":0.007,"tests\\flow\\nodes\\FinalRinseNodeTest::testCanHandleAfterMachineWashByDefault":0.005,"tests\\flow\\nodes\\FinalRinseNodeTest::testCannotHandleAfterMachineWashWhenDisabled":0.008,"tests\\flow\\nodes\\FinalRinseNodeTest::testCannotHandleNonFinalRinseReader":0.008,"tests\\flow\\nodes\\FinalRinseNodeTest::testHandleProcess":0.01,"tests\\flow\\nodes\\WashNodeTest::testNodeIdentity":0.006,"tests\\flow\\nodes\\WashNodeTest::testCanHandleWashReader":0.006,"tests\\flow\\nodes\\WashNodeTest::testCannotHandleNonWashReader":0.005,"tests\\flow\\nodes\\WashNodeTest::testCanStartNewWashProcess":0.005,"tests\\flow\\nodes\\WashNodeTest::testCannotWashWithoutMorningWash":0.006,"tests\\flow\\nodes\\WashNodeTest::testHandleProcess":0.009,"tests\\flow\\nodes\\WashNodeTest::testGenerateBatchNo":0.008,"tests\\flow\\nodes\\WashNodeTest::testKeepExistingBatchNo":0.006,"tests\\flow\\strategies\\MorningWashStrategyTest::testNoneMode":0.006,"tests\\flow\\strategies\\MorningWashStrategyTest::testAllMode":0.005,"tests\\flow\\strategies\\MorningWashStrategyTest::testDailyFirstModeWithNoRecords":0.005,"tests\\flow\\strategies\\MorningWashStrategyTest::testDailyFirstModeWithRecords":0.006,"tests\\flow\\strategies\\MorningWashStrategyTest::testSpecificTypesMode":0.005,"tests\\flow\\strategies\\MorningWashStrategyTest::testStorageTimeMode":0.006,"tests\\flow\\strategies\\MorningWashStrategyTest::testStrategyName":0.005,"tests\\flow\\strategies\\TimeValidationStrategyTest::testNoTimeLimitForFirstTime":0.005,"tests\\flow\\strategies\\TimeValidationStrategyTest::testTimeRequirementMet":0.006,"tests\\flow\\strategies\\TimeValidationStrategyTest::testTimeRequirementNotMet":0.005,"tests\\flow\\strategies\\TimeValidationStrategyTest::testCustomDuration":0.006,"tests\\flow\\strategies\\TimeValidationStrategyTest::testIsApplicable":0.006,"tests\\flow\\strategies\\TimeValidationStrategyTest::testStrategyName":0.006,"tests\\flow\\strategies\\TimeValidationStrategyTest::testStrategyPhase":0.005,"tests\\flow\\ProcessEngineTest::testSkipStep":0.012,"tests\\flow\\ProcessEngineTest::testDisinfectAfterWash":0.005,"tests\\flow\\BatchConsistencyTest::testSingleMachineBatchConsistency":0.046,"tests\\flow\\BatchConsistencyTest::testMultiEndoscopeBatchUniqueness":0.036,"tests\\flow\\BatchConsistencyTest::testBatchNoFormat":0.008,"tests\\flow\\BatchConsistencyTest::testBatchNoStability":0.02,"tests\\flow\\BatchConsistencyTest::testNewProcessGeneratesNewBatchNo":0.024,"tests\\flow\\BatchConsistencyTest::testDistributedBatchConsistency":0.057,"tests\\flow\\BatchConsistencyTest::testBatchNoPreservedOnError":0.024,"tests\\flow\\BatchConsistencyTest::testBatchNoWithDifferentProcessTypes":0.022,"tests\\flow\\EdgeCaseTest::testEmptyContext":0.005,"tests\\flow\\EdgeCaseTest::testLongEndoscopeName":0.006,"tests\\flow\\EdgeCaseTest::testSpecialCharactersInName":0.006,"tests\\flow\\EdgeCaseTest::testBatchNoGenerationEdgeCases":0.006,"tests\\flow\\EdgeCaseTest::testTimeBoundaryValues":0.006,"tests\\flow\\EdgeCaseTest::testEmptyChain":0.005,"tests\\flow\\EdgeCaseTest::testAllNodesDisabled":0.021,"tests\\flow\\EdgeCaseTest::testRepeatedStepTimeSetting":0.006,"tests\\flow\\EdgeCaseTest::testChainingEdgeCases":0.006,"tests\\flow\\EdgeCaseTest::testErrorStateOverwrite":0.006,"tests\\flow\\EdgeCaseTest::testRecoveryFromError":0.005,"tests\\flow\\EdgeCaseTest::testInvalidConfigItems":0.007,"tests\\flow\\EdgeCaseTest::testConcurrentBatchNoGeneration":0.011,"tests\\flow\\EdgeCaseTest::testMorningWashBoundaryTime":0.006,"tests\\flow\\EdgeCaseTest::testStorageTimeBoundary":0.007,"tests\\flow\\EdgeCaseTest::testStorageTimeOverThreshold":0.005,"tests\\flow\\PerformanceTest::testSingleExecutionPerformance":0.011,"tests\\flow\\PerformanceTest::testCompleteProcessPerformance":0.039,"tests\\flow\\PerformanceTest::testBatchNoGenerationPerformance":0.023,"tests\\flow\\PerformanceTest::testEngineCreationPerformance":0.094,"tests\\flow\\PerformanceTest::testConfigLoadingPerformance":0.023,"tests\\flow\\PerformanceTest::testContextCreationPerformance":0.258,"tests\\flow\\PerformanceTest::testStrategyExecutionPerformance":0.156,"tests\\flow\\PerformanceTest::testMemoryUsage":0.226,"tests\\flow\\PerformanceTest::testConcurrentProcessingSimulation":0.058,"tests\\flow\\PerformanceTest::testChainTraversalPerformance":5.796,"tests\\flow\\config\\ProcessConfigTest::testCreateDefaultConfig":0.007,"tests\\flow\\config\\ProcessConfigTest::testFromArray":0.006,"tests\\flow\\config\\ProcessConfigTest::testGetEnabledSteps":0.007,"tests\\flow\\config\\ProcessConfigTest::testAddStep":0.006,"tests\\flow\\config\\ProcessConfigTest::testRemoveStep":0.005,"tests\\flow\\config\\ProcessConfigTest::testSetNodeEnabled":0.008,"tests\\flow\\config\\ProcessConfigTest::testMorningWashConfig":0.006,"tests\\flow\\config\\ProcessConfigTest::testTimeValidationConfig":0.007,"tests\\flow\\config\\ProcessConfigTest::testVoiceTemplateConfig":0.005,"tests\\flow\\config\\ProcessConfigTest::testSetStepVoice":0.006,"tests\\flow\\config\\ProcessConfigTest::testToArray":0.007,"tests\\flow\\config\\ProcessConfigTest::testCreateStandard":0.006,"tests\\flow\\config\\ProcessConfigTest::testCreateNoMorningWash":0.007,"tests\\flow\\config\\ProcessConfigTest::testCreateSimple":0.006,"tests\\flow\\config\\ProcessConfigTest::testCreateMachineWash":0.005,"tests\\flow\\config\\ProcessConfigTest::testCreateNoDry":0.006,"tests\\flow\\config\\ProcessConfigTest::testCreateDryOnly":0.005,"tests\\flow\\config\\ProcessConfigTest::testChaining":0.006,"tests\\flow\\nodes\\DisinfectNodeTest::testNodeIdentity":0.005,"tests\\flow\\nodes\\DisinfectNodeTest::testCanHandleAfterRinse":0.005,"tests\\flow\\nodes\\DisinfectNodeTest::testCanHandleAfterFinalRinse":0.005,"tests\\flow\\nodes\\DisinfectNodeTest::testCanHandleAfterWash":0.005,"tests\\flow\\nodes\\DisinfectNodeTest::testCannotHandleNonDisinfectReader":0.005,"tests\\flow\\nodes\\DisinfectNodeTest::testCannotHandleAfterDisinfect":0.005,"tests\\flow\\nodes\\DisinfectNodeTest::testHandleProcess":0.009,"tests\\flow\\nodes\\DisinfectNodeTest::testDatabaseOperationFlags":0.008,"tests\\flow\\nodes\\DisinfectNodeTest::testWebSocketNotifyFlag":0.008,"tests\\flow\\nodes\\MorningWashNodeTest::testNodeIdentity":0.005,"tests\\flow\\nodes\\MorningWashNodeTest::testCanHandleMorningWashReader":0.021,"tests\\flow\\nodes\\MorningWashNodeTest::testCannotHandleWhenNoNeed":0.006,"tests\\flow\\nodes\\MorningWashNodeTest::testCannotHandleNonMorningWashReader":0.005,"tests\\flow\\nodes\\MorningWashNodeTest::testCannotHandleWhenAlreadyWashed":0.005,"tests\\flow\\nodes\\MorningWashNodeTest::testHandleProcess":0.008,"tests\\flow\\nodes\\MorningWashNodeTest::testGenerateBatchNo":0.009,"tests\\flow\\nodes\\MorningWashNodeTest::testCanHandleDisinfectReaderForMorningWash":0.006,"tests\\flow\\nodes\\MorningWashNodeTest::testCanHandleMachineWashReaderForMorningWash":0.005,"tests\\flow\\nodes\\MorningWashNodeTest::testCannotHandleNonDisinfectMachineReader":0.006,"tests\\flow\\nodes\\MorningWashNodeTest::testHandleProcessWithDisinfect":0.009,"tests\\flow\\nodes\\MorningWashNodeTest::testHandleProcessWithMachineWash":0.009,"tests\\flow\\nodes\\DryNodeTest::testNodeIdentity":0.006,"tests\\flow\\nodes\\DryNodeTest::testCanHandleAfterFinalRinse":0.007,"tests\\flow\\nodes\\DryNodeTest::testCanHandleAfterDisinfect":0.006,"tests\\flow\\nodes\\DryNodeTest::testCannotHandleAfterRinse":0.005,"tests\\flow\\nodes\\DryNodeTest::testCannotHandleNonDryReader":0.007,"tests\\flow\\nodes\\DryNodeTest::testCannotHandleAfterDry":0.006,"tests\\flow\\nodes\\DryNodeTest::testHandleProcess":0.008,"tests\\flow\\nodes\\DryNodeTest::testDatabaseOperationFlags":0.009,"tests\\flow\\nodes\\DryNodeTest::testWebSocketNotifyFlag":0.008,"tests\\flow\\nodes\\DryNodeTest::testKeepExistingBatchNo":0.01,"tests\\flow\\ProcessEngineTest::testStandardWashRinseDisinfectFlow":0.023,"tests\\flow\\UsageExampleTest::testExample1StandardProcess":0.022,"tests\\flow\\UsageExampleTest::testExample2NoMorningWash":0.025,"tests\\flow\\UsageExampleTest::testExample3DynamicAdjust":0.048,"tests\\flow\\UsageExampleTest::testExample4CustomVoice":0.022,"tests\\flow\\UsageExampleTest::testExample5MorningWashModes":0.033,"tests\\flow\\UsageExampleTest::testExample6MachineWash":0.022,"tests\\flow\\UsageExampleTest::testExample7SimpleProcess":0.021,"tests\\flow\\UsageExampleTest::testExample8TimeValidation":0.021,"tests\\flow\\UsageExampleTest::testExample9FullProcess":0.021,"tests\\flow\\UsageExampleTest::testExample10MultiHospital":0.046,"tests\\flow\\UsageExampleTest::testLoadCustomProcessFromConfig":0.007,"tests\\flow\\UsageExampleTest::testCreateNoMorningWashFromConfig":0.042,"tests\\flow\\UsageExampleTest::testCreateMachineWashFromConfig":0.036,"tests\\flow\\UsageExampleTest::testCreateYiwuModeFromConfig":0.015,"tests\\flow\\UsageExampleTest::testFlowProcessorUsage":0.045,"tests\\flow\\UsageExampleTest::testFlowProcessorCompleteProcess":0.021,"tests\\flow\\UsageExampleTest::testFlowProcessorWithConfig":0.037,"tests\\db\\DBC::testDBConnect":0.113,"tests\\db\\DBC::testGenTables":0.416,"tests\\db\\DBC::testModel":0.171,"tests\\flow\\nodes\\EndNodeTest::testNodeIdentity":0.006,"tests\\flow\\nodes\\EndNodeTest::testCanHandleAfterDry":0.006,"tests\\flow\\nodes\\EndNodeTest::testCanHandleAfterDisinfect":0.005,"tests\\flow\\nodes\\EndNodeTest::testCanHandleAfterFinalRinse":0.005,"tests\\flow\\nodes\\EndNodeTest::testCanHandleAfterMachineWash":0.005,"tests\\flow\\nodes\\EndNodeTest::testCannotHandleNonEndReader":0.005,"tests\\flow\\nodes\\EndNodeTest::testCannotHandleAfterWash":0.007,"tests\\flow\\nodes\\EndNodeTest::testCannotHandleAfterRinse":0.006,"tests\\flow\\nodes\\EndNodeTest::testCannotHandleWithEmptyStep":0.005,"tests\\flow\\nodes\\EndNodeTest::testHandleProcess":0.009,"tests\\flow\\nodes\\EndNodeTest::testDatabaseOperationIsUpdate":0.011,"tests\\flow\\nodes\\EndNodeTest::testWebSocketNotifyFlag":0.009,"tests\\flow\\nodes\\EndNodeTest::testActionEndTimeIsSet":0.009,"tests\\flow\\nodes\\FinalRinseNodeTest::testCannotHandleAfterWash":0.006,"tests\\flow\\nodes\\FinalRinseNodeTest::testDatabaseOperationFlags":0.008,"tests\\flow\\nodes\\FinalRinseNodeTest::testWebSocketNotifyFlag":0.009,"tests\\flow\\nodes\\FinalRinseNodeTest::testNodeIdentity":0.005,"tests\\flow\\nodes\\MachineWashNodeTest::testNodeIdentity":0.005,"tests\\flow\\nodes\\MachineWashNodeTest::testCanHandleAfterWash":0.005,"tests\\flow\\nodes\\MachineWashNodeTest::testCanHandleAfterRinse":0.005,"tests\\flow\\nodes\\MachineWashNodeTest::testCanHandleAfterDisinfect":0.005,"tests\\flow\\nodes\\MachineWashNodeTest::testCanHandleWithEmptyStep":0.006,"tests\\flow\\nodes\\MachineWashNodeTest::testCanHandleAfterEnd":0.006,"tests\\flow\\nodes\\MachineWashNodeTest::testCanHandleAfterEndoscopeOut":0.005,"tests\\flow\\nodes\\MachineWashNodeTest::testCannotHandleNonMachineWashReader":0.005,"tests\\flow\\nodes\\MachineWashNodeTest::testCannotHandleAfterFinalRinse":0.005,"tests\\flow\\nodes\\MachineWashNodeTest::testCannotHandleAfterDry":0.006,"tests\\flow\\nodes\\MachineWashNodeTest::testHandleProcess":0.008,"tests\\flow\\nodes\\MachineWashNodeTest::testDatabaseOperationFlags":0.01,"tests\\flow\\nodes\\MachineWashNodeTest::testWebSocketNotifyFlag":0.008,"tests\\flow\\nodes\\RinseNodeTest::testNodeIdentity":0.005,"tests\\flow\\nodes\\RinseNodeTest::testCanHandleAfterWash":0.007,"tests\\flow\\nodes\\RinseNodeTest::testCannotHandleNonRinseReader":0.007,"tests\\flow\\nodes\\RinseNodeTest::testCannotHandleAfterDisinfect":0.006,"tests\\flow\\nodes\\RinseNodeTest::testCannotHandleWithEmptyStep":0.006,"tests\\flow\\nodes\\RinseNodeTest::testCannotHandleAfterRinse":0.005,"tests\\flow\\nodes\\RinseNodeTest::testHandleProcess":0.01,"tests\\flow\\nodes\\RinseNodeTest::testDatabaseOperationFlags":0.009,"tests\\flow\\nodes\\RinseNodeTest::testWebSocketNotifyFlag":0.009,"tests\\flow\\nodes\\RinseNodeTest::testDisabledNodeSkips":0.009,"tests\\flow\\strategies\\TimeValidationStrategyTest::testNonDurationStepIsSkipped":0.008,"tests\\flow\\strategies\\TimeValidationStrategyTest::testSetStepDuration":0.006,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testStrategyName":0.007,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testStrategyPhaseIsAfter":0.006,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testIsAlwaysApplicable":0.007,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testNormalWashVoice":0.005,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testNormalRinseVoice":0.005,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testNormalDisinfectVoice":0.005,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testFinalRinseVoice":0.006,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testDryVoice":0.006,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testEndVoice":0.005,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testMachineWashVoice":0.007,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testMorningWashVoice":0.005,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testMachineMorningWashVoice":0.006,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testErrorStateGeneratesErrorVoice":0.006,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testFullVoiceIncludesEndoscopeName":0.006,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testLeakTestRemindAppended":0.006,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testStorageRemindAppended":0.005,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testCustomVoiceTemplate":0.005,"tests\\flow\\strategies\\VoiceGenerationStrategyTest::testSetStepVoice":0.005,"tests\\flow\\FlowProcessorTest::testSuccessfulFlowSavesToDatabase":0.011,"tests\\flow\\FlowProcessorTest::testSuccessfulFlowSendsVoice":0.008,"tests\\flow\\FlowProcessorTest::testFailedFlowDoesNotSaveToDatabase":0.007,"tests\\flow\\FlowProcessorTest::testFailedFlowStillSendsVoice":0.008,"tests\\flow\\FlowProcessorTest::testWebSocketNotificationSentWhenNeeded":0.01,"tests\\flow\\FlowProcessorTest::testWebSocketNotificationNotSentWhenNotNeeded":0.008,"tests\\flow\\FlowProcessorTest::testEndOperationIsUpdate":0.01,"tests\\flow\\FlowProcessorTest::testGetActionTypeMapping":0.107,"tests\\flow\\FlowProcessorTest::testStaticFactoryCreate":0.007}}
\ No newline at end of file
diff --git a/webman/Dockerfile b/Dockerfile
similarity index 100%
rename from webman/Dockerfile
rename to Dockerfile
diff --git a/webman/LICENSE b/LICENSE
similarity index 100%
rename from webman/LICENSE
rename to LICENSE
diff --git a/PHPUnit.php b/PHPUnit.php
new file mode 100644
index 0000000..9b7abf5
--- /dev/null
+++ b/PHPUnit.php
@@ -0,0 +1,49 @@
+dbDebug) Db::connection()->listen(function (QueryExecuted $queryExecuted) use ($appPath) {
+ // 过滤掉 "select 1" 这类心跳检测SQL
+ if (isset($queryExecuted->sql) && $queryExecuted->sql !== "select 1") {
+ $bindings = $queryExecuted->bindings;
+ // 替换SQL中的?为实际绑定参数
+ $sql = array_reduce(
+ $bindings,
+ function ($sql, $binding) {
+ // 处理参数类型:字符串加引号,数值/布尔直接使用,null显示为NULL
+ $value = match (true) {
+ is_string($binding) => "'{$binding}'",
+ is_null($binding) => 'NULL',
+ is_bool($binding) => $binding ? 1 : 0,
+ default => $binding
+ };
+ return preg_replace('/\?/', $value, $sql, 1);
+ },
+ $queryExecuted->sql
+ );
+
+ // 构造基础SQL日志信息
+ $sqlLog = sprintf(
+ "%s",
+ $sql
+ );
+
+ // 定位产生SQL的业务文件/行号/方法
+ $traces = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
+ foreach ($traces as $trace) {
+ if (isset($trace['file'], $trace['function']) && str_contains($trace['file'], $appPath)) {
+ // 格式化文件路径(去掉项目根目录,只保留相对路径)
+ $file = str_replace(base_path(), '', $trace['file']);
+ $file = ltrim($file, '/\\');
+ $file = str_replace(".php", '', $file);
+ $file = str_replace("\\", '.', $file);
+ $file = str_replace("/", '.', $file);
+// $file = basename($file);
+
+ // 使用Logger::debug输出日志(会同时输出到控制台和日志文件)
+ Log::debug(
+ '' . $file . "{$trace['line']}{$trace['function']}[$queryExecuted->time ms] " . $sqlLog
+ );
+ break; // 只打印第一个匹配的业务文件信息,避免重复输出
+ }
+ }
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/app/config/Config.php b/app/config/Config.php
new file mode 100644
index 0000000..a43662e
--- /dev/null
+++ b/app/config/Config.php
@@ -0,0 +1,232 @@
+ $this->flowUseCustomProcess;
+ }
+
+ /**
+ * 自定义流程配置键名(默认使用 'standard' 注意:此次为空,并非使用自定义配置文件里面的 'standard')
+ */
+ public string $flowProcessConfigKey {
+ get => $this->flowProcessConfigKey;
+ }
+
+ /**
+ * @var int 普通日志轮转时间默认 14 天
+ */
+ public int $logRotationTimeByDay {
+ get => $this->logRotationTimeByDay;
+ }
+
+ /**
+ * @var int 错误日志轮转时间默认 30 天
+ */
+ public int $errorLogRotationTimeByDay {
+ get => $this->errorLogRotationTimeByDay;
+ }
+
+ public DatabaseConfig $database {
+ get => $this->database;
+ }
+
+ public array $customProcess {
+ get => $this->customProcess;
+ }
+
+ /**
+ * 存储单读卡器模式
+ * true=单读卡器模式(一个读卡器交替入库/出库)
+ * false=双读卡器模式(分别使用"内镜放入"和"内镜取出"读卡器)
+ */
+ public bool $storageSingleReader {
+ get => $this->storageSingleReader;
+ }
+
+ /**
+ * 机器ID,用于分布式环境,确保唯一
+ */
+ public string $machineId {
+ get => $this->machineId;
+ }
+
+ public bool $dbDebug {
+ get => $this->dbDebug;
+ }
+
+ /**
+ * 方式 类:方法
+ * 方式 类
+ * 方式 :方法
+ * 允许使用正则 * 作为通配符
+ * @var array 日志过滤器
+ */
+ public array $logFilter {
+ get => $this->logFilter;
+ }
+
+ public int $logLevel {
+ get => $this->logLevel;
+ }
+
+ /**
+ * 阻断模式
+ */
+ public bool $blockMode {
+ get => $this->blockMode;
+ }
+
+ /**
+ * 人员刷卡记录缓存记录多久。然后是 0 表示不计算缓存时间,只要被使用了就删除
+ */
+ public int $openCardRecordCacheTime = 0 {
+ get => $this->openCardRecordCacheTime;
+ }
+
+ /**
+ * 人员刷卡记录记录哪些读卡器的
+ * 设置为空则表示所有读卡器
+ */
+ public array $openCardRecordReaders = [] {
+ get => $this->openCardRecordReaders;
+ }
+
+ private function __construct()
+ {
+ $this->database = new DatabaseConfig();
+ $this->customProcess = require __DIR__ . '/custom_process_config.php';
+ $this->machineId = self::getStringEnv("MACHINE_ID", "0");
+ if (empty($this->machineId) || $this->machineId == '0') {
+ $this->machineId = $this->getMachineIdByMac();
+ }
+ $this->dbDebug = self::getBoolEnv('DB_DEBUG');
+ $this->flowUseCustomProcess = self::getBoolEnv('FLOW_USE_CUSTOM_PROCESS', true);
+ $this->flowProcessConfigKey = self::getStringEnv('FLOW_PROCESS_CONFIG_KEY', '');
+ $this->logRotationTimeByDay = self::getIntEnv('LOG_ROTATION_TIME_BY_DAY', 14);
+ $this->errorLogRotationTimeByDay = self::getIntEnv('ERROR_LOG_ROTATION_TIME_BY_DAY', 30);
+ $this->logFilter = self::getStringArrayEnv('LOG_FILTER', []);
+ $this->logLevel = match (strtoupper(self::getStringEnv('LOG_LEVEL', 'DEBUG'))) {
+ 'INFO' => \Monolog\Logger::INFO,
+ 'WARNING' => \Monolog\Logger::WARNING,
+ 'ERROR' => \Monolog\Logger::ERROR,
+ 'ALERT' => \Monolog\Logger::ALERT,
+ 'EMERGENCY' => \Monolog\Logger::EMERGENCY,
+ 'CRITICAL' => \Monolog\Logger::CRITICAL,
+ 'NOTICE' => \Monolog\Logger::NOTICE,
+ default => \Monolog\Logger::DEBUG
+ };
+ $this->blockMode = self::getBoolEnv('BLOCK_MODE', true);
+ $this->openCardRecordCacheTime = self::getIntEnv('OPEN_CARD_RECORD_CACHE_TIME', 60);
+ $this->openCardRecordReaders = self::getStringArrayEnv('OPEN_CARD_RECORD_READERS', []);
+ $this->storageSingleReader = self::getBoolEnv('STORAGE_SINGLE_READER', false);
+ }
+
+ /**
+ * 获取服务器MAC地址并散列成2位数字(00-99)作为机器ID
+ * 兼容Linux/Windows系统,失败时降级使用IP散列
+ *
+ * @return string 2位机器ID(00-99)
+ */
+ public function getMachineIdByMac(): string
+ {
+ try {
+ // 步骤1:根据系统类型执行命令获取MAC地址
+ $mac = '';
+ if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
+ // Windows系统
+ $output = shell_exec('ipconfig /all');
+ if (preg_match('/物理地址[.:\s]+([0-9A-F]{2}[-:][0-9A-F]{2}[-:][0-9A-F]{2}[-:][0-9A-F]{2}[-:][0-9A-F]{2}[-:][0-9A-F]{2})/i', $output, $matches)) {
+ $mac = str_replace(['-', ':'], '', strtolower($matches[1]));
+ }
+ } else {
+ // Linux/Mac系统
+ $output = shell_exec('cat /sys/class/net/*/address 2>/dev/null || ifconfig');
+ if (preg_match('/([0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2})/i', $output, $matches)) {
+ // 过滤虚拟网卡(docker/lo等),取第一个有效物理网卡MAC
+ $mac = str_replace(':', '', strtolower($matches[1]));
+ // 排除回环地址、docker虚拟网卡等无效MAC
+ $invalidPrefixes = ['00:00:00', '02:42:ac', '12:34:56'];
+ $isInvalid = false;
+ foreach ($invalidPrefixes as $prefix) {
+ if (str_starts_with($matches[1], $prefix)) {
+ $isInvalid = true;
+ break;
+ }
+ }
+ if ($isInvalid) {
+ $mac = '';
+ }
+ }
+ }
+
+ // 步骤2:若MAC获取失败,降级使用服务器IP
+ if (empty($mac)) {
+ // 获取外网/内网IP(优先内网)
+ $ip = $_SERVER['SERVER_ADDR'] ?? gethostbyname(gethostname());
+ if (empty($ip) || $ip === '127.0.0.1') {
+ $ip = '0.0.0.0'; // 兜底
+ }
+ $mac = $ip; // 用IP替代MAC做散列
+ }
+
+ $hash = md5($mac); // 生成32位哈希值
+ $hashInt = hexdec(substr($hash, 0, 4));
+ $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
+ $base = 62; // 62进制基数
+ $char1 = $chars[$hashInt % $base];
+ $char2 = $chars[(int)($hashInt / $base) % $base];
+ $machineId = $char2 . $char1;
+
+ // 步骤4:补零为2位(如5→05,99→99)
+ return str_pad($machineId, 2, '0', STR_PAD_LEFT);
+ } catch (\Exception $e) {
+ // 极端异常时返回默认值(建议日志记录)
+ Logger::error('获取机器ID失败:', ['error' => $e->getMessage()]);
+ return '01'; // 默认机器ID
+ }
+ }
+
+ public static function getBoolEnv($name, $default = false)
+ {
+ $value = getenv($name);
+ return empty($value) ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN);
+ }
+
+ public static function getIntEnv($name, $default = 0)
+ {
+ $value = getenv($name);
+ return empty($value) ? $default : (int)$value;
+ }
+
+ public static function getStringEnv($name, $default = null)
+ {
+ $value = getenv($name);
+ return empty($value) ? $default : $value;
+ }
+
+ private static ?Config $instance = null;
+
+ public static function getInstance(): Config
+ {
+ if (self::$instance === null) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ private static function getStringArrayEnv(string $string, array $array): array
+ {
+ $value = getenv($string);
+ return empty($value) ? $array : array_map('trim', explode(';', $value));
+ }
+
+}
\ No newline at end of file
diff --git a/app/config/DatabaseConfig.php b/app/config/DatabaseConfig.php
new file mode 100644
index 0000000..21f771a
--- /dev/null
+++ b/app/config/DatabaseConfig.php
@@ -0,0 +1,48 @@
+host;
+ }
+ }
+ public string $username = 'root' {
+ get {
+ return $this->username;
+ }
+ }
+ public string $password = '' {
+ get {
+ return $this->password;
+ }
+ }
+ public string $database = 'opm_ectms' {
+ get {
+ return $this->database;
+ }
+ }
+
+ public function __construct()
+ {
+ $this->host = getenv('DB_HOST');
+ $this->username = getenv('DB_USER');
+ $this->password = getenv('DB_PASSWORD');
+ $this->database = getenv('DB_NAME');
+ }
+
+ /**
+ * @param $connection string|null
+ * @return Connection
+ */
+ public function getConnection(?string $connection = null): Connection
+ {
+ return Db::connection($connection);
+ }
+}
\ No newline at end of file
diff --git a/app/config/custom_process_config.php b/app/config/custom_process_config.php
new file mode 100644
index 0000000..994c77d
--- /dev/null
+++ b/app/config/custom_process_config.php
@@ -0,0 +1,271 @@
+ [
+ 'name' => '标准完整流程',
+ 'description' => '包含所有步骤的完整清洗流程',
+ // 覆盖 steps
+ 'override_steps' => false,
+ 'morning_wash' => [
+ 'mode' => 'daily_first', // 每天第一次需要晨洗
+ 'storage_threshold' => 4,
+ 'morning_start_time' => '00:00:00',
+ ],
+ 'steps' => [
+ ['code' => '晨洗', 'class' => 'MorningWashNode', 'enabled' => true],
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true,'required' => ['结束','晨洗']],
+ ['code' => '漂洗', 'class' => 'RinseNode', 'enabled' => true],
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => true],
+ ['code' => '终末漂洗', 'class' => 'FinalRinseNode', 'enabled' => true],
+ ['code' => '干燥', 'class' => 'DryNode', 'enabled' => true],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ['code' => '机洗', 'class' => 'MachineWashNode', 'enabled' => true],
+ ],
+ 'time_validation' => [
+ '手工洗' => [
+ '清洗' => 120,
+ '漂洗' => 60,
+ '消毒' => 300,
+ '终末漂洗' => 120,
+ '干燥' => 30,
+ '机洗' => 360,
+ ]
+ ],
+ ],
+
+ // ============================================
+ // 示例2: 无晨洗流程
+ // 医院不需要晨洗功能
+ // 清洗→漂洗→消毒→终末漂洗→干燥→结束
+ // ============================================
+ 'no_morning_wash' => [
+ 'name' => '无晨洗流程',
+ 'description' => '医院不需要晨洗功能',
+ 'morning_wash' => [
+ 'mode' => 'none', // 完全禁用晨洗
+ ],
+ 'steps' => [
+ ['code' => '晨洗', 'class' => 'MorningWashNode', 'enabled' => false], // 禁用晨洗节点
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true],
+ ['code' => '漂洗', 'class' => 'RinseNode', 'enabled' => true],
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => true],
+ ['code' => '终末漂洗', 'class' => 'FinalRinseNode', 'enabled' => true],
+ ['code' => '干燥', 'class' => 'DryNode', 'enabled' => true],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ['code' => '机洗', 'class' => 'MachineWashNode', 'enabled' => true],
+ ],
+ ],
+
+ // ============================================
+ // 示例3: 部分镜子需要晨洗(义乌模式)
+ // 根据存储时间判断:超过4小时需要晨洗
+ // ============================================
+ 'partial_morning_wash' => [
+ 'name' => '部分镜子晨洗(义乌模式)',
+ 'description' => '普通镜柜超过4小时需要晨洗,无菌镜柜免晨消',
+ 'morning_wash' => [
+ 'mode' => 'storage_time', // 根据存储时间判断
+ 'storage_threshold' => 4, // 4小时阈值
+ 'morning_start_time' => '06:00:00',
+ ],
+ 'steps' => [
+ ['code' => '晨洗', 'class' => 'MorningWashNode', 'enabled' => true],
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true],
+ ['code' => '漂洗', 'class' => 'RinseNode', 'enabled' => true],
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => true],
+ ['code' => '终末漂洗', 'class' => 'FinalRinseNode', 'enabled' => true],
+ ['code' => '干燥', 'class' => 'DryNode', 'enabled' => true],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ['code' => '机洗', 'class' => 'MachineWashNode', 'enabled' => true],
+ ],
+ ],
+
+ // ============================================
+ // 示例4: 无干燥流程
+ // 医院不需要干燥步骤,消毒后直接结束
+ // 清洗→漂洗→消毒→结束
+ // ============================================
+ 'no_dry' => [
+ 'name' => '无干燥流程',
+ 'description' => '医院不需要干燥步骤',
+ 'morning_wash' => [
+ 'mode' => 'none',
+ ],
+ 'steps' => [
+ ['code' => '晨洗', 'class' => 'MorningWashNode', 'enabled' => false],
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true],
+ ['code' => '漂洗', 'class' => 'RinseNode', 'enabled' => true],
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => true],
+ ['code' => '终末漂洗', 'class' => 'FinalRinseNode', 'enabled' => false], // 跳过终末漂洗
+ ['code' => '干燥', 'class' => 'DryNode', 'enabled' => false], // 禁用干燥
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ['code' => '机洗', 'class' => 'MachineWashNode', 'enabled' => true],
+ ],
+ ],
+
+ // ============================================
+ // 示例5: 仅干燥流程
+ // 医院只有干燥步骤,没有完整清洗流程
+ // 干燥→结束
+ // ============================================
+ 'dry_only' => [
+ 'name' => '仅干燥流程',
+ 'description' => '医院只有干燥步骤',
+ 'morning_wash' => [
+ 'mode' => 'none',
+ ],
+ 'steps' => [
+ ['code' => '晨洗', 'class' => 'MorningWashNode', 'enabled' => false],
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => false],
+ ['code' => '漂洗', 'class' => 'RinseNode', 'enabled' => false],
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => false],
+ ['code' => '终末漂洗', 'class' => 'FinalRinseNode', 'enabled' => false],
+ ['code' => '干燥', 'class' => 'DryNode', 'enabled' => true],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ['code' => '机洗', 'class' => 'MachineWashNode', 'enabled' => false],
+ ],
+ ],
+
+ // ============================================
+ // 示例6: 机洗流程
+ // 清洗→机洗→终末漂洗→干燥→结束
+ // ============================================
+ 'machine_wash' => [
+ 'name' => '机洗流程',
+ 'description' => '刷完机洗后接终末漂洗再干燥结束',
+ 'morning_wash' => [
+ 'mode' => 'none',
+ ],
+ 'steps' => [
+ ['code' => '晨洗', 'class' => 'MorningWashNode', 'enabled' => false],
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true],
+ ['code' => '漂洗', 'class' => 'RinseNode', 'enabled' => false], // 跳过硬洗漂洗
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => false], // 跳过硬洗消毒
+ ['code' => '机洗', 'class' => 'MachineWashNode', 'enabled' => true],
+ ['code' => '终末漂洗', 'class' => 'FinalRinseNode', 'enabled' => true],
+ ['code' => '干燥', 'class' => 'DryNode', 'enabled' => true],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ],
+ ],
+
+ // ============================================
+ // 示例7: 简化流程
+ // 只刷一个清洗就结束
+ // 清洗→结束
+ // ============================================
+ 'simple' => [
+ 'name' => '简化流程',
+ 'description' => '只刷一个清洗就结束',
+ 'morning_wash' => [
+ 'mode' => 'none',
+ ],
+ 'steps' => [
+ ['code' => '晨洗', 'class' => 'MorningWashNode', 'enabled' => false],
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true],
+ ['code' => '漂洗', 'class' => 'RinseNode', 'enabled' => false],
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => false],
+ ['code' => '终末漂洗', 'class' => 'FinalRinseNode', 'enabled' => false],
+ ['code' => '干燥', 'class' => 'DryNode', 'enabled' => false],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ['code' => '机洗', 'class' => 'MachineWashNode', 'enabled' => false],
+ ],
+ ],
+
+ // ============================================
+ // 示例8: 自定义语音流程
+ // 针对某个流程特殊定制语音
+ // ============================================
+ 'custom_voice' => [
+ 'name' => '自定义语音流程',
+ 'description' => '针对流程特殊定制语音',
+ 'morning_wash' => [
+ 'mode' => 'daily_first',
+ ],
+ 'steps' => [
+ ['code' => '晨洗', 'class' => 'MorningWashNode', 'enabled' => true],
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true],
+ ['code' => '漂洗', 'class' => 'RinseNode', 'enabled' => true],
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => true],
+ ['code' => '终末漂洗', 'class' => 'FinalRinseNode', 'enabled' => true],
+ ['code' => '干燥', 'class' => 'DryNode', 'enabled' => true],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ['code' => '机洗', 'class' => 'MachineWashNode', 'enabled' => true],
+ ],
+ 'voice_templates' => [
+ // 与 VoiceGenerationStrategy 中默认配置进行合并
+ 'normal_wash' => [
+ '清洗' => '第一步清洗开始,请认真清洗',
+ '漂洗' => '第二步漂洗开始',
+ '消毒' => '第三步消毒开始,请确保消毒时间',
+ '终末漂洗' => '第四步终末漂洗开始',
+ '干燥' => '最后一步干燥开始',
+ '结束' => '清洗流程全部完成,请妥善保管内镜',
+ ],
+ ],
+ ],
+
+ // ============================================
+ // 示例9: 特定类型镜子晨洗
+ // 只有特定类型的镜子需要晨洗
+ // ============================================
+ 'specific_type_morning_wash' => [
+ 'name' => '特定类型镜子晨洗',
+ 'description' => '只有胃镜需要晨洗,肠镜不需要',
+ 'morning_wash' => [
+ 'mode' => 'specific_types',
+ 'specific_types' => ['胃镜', '十二指肠镜'], // 只有这些类型需要晨洗
+ ],
+ 'steps' => [
+ ['code' => '晨洗', 'class' => 'MorningWashNode', 'enabled' => true],
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true],
+ ['code' => '漂洗', 'class' => 'RinseNode', 'enabled' => true],
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => true],
+ ['code' => '终末漂洗', 'class' => 'FinalRinseNode', 'enabled' => true],
+ ['code' => '干燥', 'class' => 'DryNode', 'enabled' => true],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ['code' => '机洗', 'class' => 'MachineWashNode', 'enabled' => true],
+ ],
+ ],
+
+ // ============================================
+ // 示例10: 自定义时间要求
+ // 不同医院对步骤时间有不同要求
+ // ============================================
+ 'custom_duration' => [
+ 'name' => '自定义时间要求',
+ 'description' => '消毒时间要求10分钟',
+ // 覆盖 steps
+ 'override_steps' => false,
+ 'morning_wash' => [
+ 'mode' => 'daily_first',
+ ],
+ 'steps' => [
+ ['code' => '晨洗', 'class' => 'MorningWashNode', 'enabled' => true],
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true],
+ ['code' => '漂洗', 'class' => 'RinseNode', 'enabled' => true],
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => true],
+ ['code' => '终末漂洗', 'class' => 'FinalRinseNode', 'enabled' => true],
+ ['code' => '干燥', 'class' => 'DryNode', 'enabled' => true],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ['code' => '机洗', 'class' => 'MachineWashNode', 'enabled' => true],
+ ],
+ 'time_validation' => [
+ 'durations' => [
+ '清洗' => 300, // 5分钟
+ '漂洗' => 120, // 2分钟
+ '消毒' => 600, // 10分钟(自定义)
+ '终末漂洗' => 180, // 3分钟(自定义)
+ '干燥' => 300, // 5分钟(自定义)
+ ],
+ ],
+ ],
+];
diff --git a/webman/app/controller/IndexController.php b/app/controller/IndexController.php
similarity index 100%
rename from webman/app/controller/IndexController.php
rename to app/controller/IndexController.php
diff --git a/app/flow/DbOperationType.php b/app/flow/DbOperationType.php
new file mode 100644
index 0000000..e2c4654
--- /dev/null
+++ b/app/flow/DbOperationType.php
@@ -0,0 +1,17 @@
+flowProcessor = $this->createFlowProcessor();
+
+ }
+
+ /**
+ * 创建流程处理器
+ *
+ * 根据配置创建对应的 FlowProcessor
+ */
+ private function createFlowProcessor(): FlowProcessor
+ {
+ $useCustomProcess = Config::getInstance()->flowUseCustomProcess;
+ $processConfigKey = Config::getInstance()->flowProcessConfigKey;
+
+ // 流程配置 通过流程配置可以实现,加载/卸载流程节点,添加/修改语音输出
+ $processConfig = null;
+ if ($useCustomProcess && !empty($processConfigKey)) {
+ // 从全局配置加载自定义流程
+ $globalConfig = Config::getInstance();
+ $customProcess = $globalConfig->customProcess;
+ $processConfigArray = $customProcess[$processConfigKey] ?? null;
+
+ if ($processConfigArray !== null) {
+ Logger::info("使用自定义流程: {}", [$processConfigKey]);
+ $processConfig = ProcessConfig::fromArray($processConfigArray);
+ return FlowProcessor::create($processConfig);
+ }
+ }
+
+ Logger::info("FlowMain 使用默认标准流程");
+ Logger::info("使用数据库: {}",[ Config::getInstance()->database->database]);
+ $processConfig = ProcessConfig::createStandard();
+ // 使用默认标准流程
+ return FlowProcessor::create($processConfig);
+ }
+
+ /**
+ * Flow主入口方法
+ *
+ * @param PacketContext $packetContext 数据包上下文
+ * @return ProcessContext
+ */
+ public function main(PacketContext $packetContext): ProcessContext
+ {
+ // 调用流程处理器处理数据包并返回处理完成后的上下文
+ // process 参数:数据包上下文
+ // process 参数: 是否执行结果处理器,默认是执行
+ // 返回值: 处理完成后的数据包上下文
+ $packetContext = $this->flowProcessor->process($packetContext);
+ return $packetContext;
+ }
+
+ /**
+ * 获取流程处理器实例
+ *
+ * @return FlowProcessor
+ */
+ public function getFlowProcessor(): FlowProcessor
+ {
+ return $this->flowProcessor;
+ }
+
+ /**
+ * 重新加载配置(配置热更新)
+ *
+ * @return void
+ */
+ public function reloadConfig(): void
+ {
+ $this->flowProcessor = $this->createFlowProcessor();
+ }
+
+ /**
+ * 切换流程配置
+ *
+ * @param string $configKey 配置键名(如 'standard', 'no_morning_wash')
+ * @return void
+ */
+ public function switchConfig(string $configKey): void
+ {
+ self::$processConfigKey = $configKey;
+ $this->reloadConfig();
+ }
+
+ /**
+ * 重置单例(主要用于测试)
+ *
+ * @return void
+ */
+ public static function resetInstance(): void
+ {
+ self::$instance = null;
+ }
+}
diff --git a/app/flow/FlowProcessor.php b/app/flow/FlowProcessor.php
new file mode 100644
index 0000000..dc5570d
--- /dev/null
+++ b/app/flow/FlowProcessor.php
@@ -0,0 +1,284 @@
+engine;
+ }
+ }
+
+ /**
+ * 构造函数
+ * @param ProcessEngine|ProcessConfig $value 流程引擎或流程配置
+ */
+ public function __construct(ProcessEngine|ProcessConfig $value)
+ {
+ $config = $value instanceof ProcessConfig ? $value : new ProcessConfig();
+ $this->engine = $value instanceof ProcessEngine ? $value : ProcessEngine::create($config);
+ }
+
+ /**
+ * 处理刷卡请求
+ *
+ * @param PacketContext $packetContext 刷卡数据包上下文
+ * @param bool $isExecuteHandleResult 是否执行处理结果
+ * @return ProcessContext 处理结果上下文
+ */
+ public function process(PacketContext $packetContext, bool $isExecuteHandleResult = true): ProcessContext
+ {
+ // 从 PacketContext 创建 ProcessContext
+ $context = ProcessContext::fromPacketContext($packetContext,["engineConfig"=>$this->engine->getConfig()]);
+
+ Logger::debug("[{}] card: {} reader({}): {} {}: {} step: {}", [
+ $context->isOperatorCard ? '人员卡' : '内镜卡',
+ $context->cardNo,
+ empty($context->readerNo) ? '(未绑定)' : $context->readerNo,
+ $context->readerNo,
+ $context->isOperatorCard ? 'user' : 'endoscope',
+ $context->isOperatorCard ? $context->operatorName : ($context->endoscopeName ?: $context->endoscopeId ?: '(未绑定)'),
+ $context->currentStep ?: '(新流程)',
+ ]);
+
+ // 读卡器未绑定
+ if (empty($context->readerId)) {
+ Logger::error('读卡器未绑定,放弃处理 card={}', [$context->cardNo]);
+ $context->setError(VoiceMessage::READER_NOT_BOUND);
+ $this->sendVoice($context);
+ return $context;
+ }
+
+ // 如果是人员卡,记录操作员信息后直接返回,不走流程链
+ if ($context->isOperatorCard) {
+ $mgr = OperatorSessionManager::getInstance();
+ $mgr->setOperator(
+ $context->readerId,
+ $context->operatorId,
+ $context->operatorName,
+ $context->operatorRfid
+ );
+ $context->setError(VoiceMessage::PLEASE_SWIPE_ENDOSCOPE);
+ $this->sendVoice($context);
+ return $context;
+ }
+
+ // 如果内镜未绑定,返回错误
+ if (empty($context->endoscopeId)) {
+ Logger::error('内镜未绑定,放弃处理 card={}', [$context->cardNo]);
+ $context->setError(VoiceMessage::CARD_NOT_BOUND);
+ $this->sendVoice($context);
+ return $context;
+ }
+
+ // 内镜卡:从 OperatorSessionManager 补充操作员信息
+ $mgr = OperatorSessionManager::getInstance();
+ if ($mgr->hasOperator($context->readerId) || $context->hasOperator()) {
+ if ($mgr->hasOperator($context->readerId)) {
+ $op = $mgr->getOperator($context->readerId, $context->readerType);
+ $context->operatorId = $op['id'];
+ $context->operatorName = $op['name'];
+ $context->operatorRfid = $op['rfid'];
+ }
+ } else {
+ // 未刷人员卡
+ Logger::warn('未刷人员卡 reader={} card={}', [
+ $context->readerId,
+ $context->cardNo,
+ ]);
+ // 处理这个错误
+ $context->setError(VoiceMessage::PLEASE_SWIPE_OPERATOR);
+ $this->handleResult($context);
+ return $context;
+ }
+
+ // 执行流程
+ $result = $this->engine->execute($context);
+
+ // 处理结果
+ if ($isExecuteHandleResult) $this->handleResult($result);
+
+ // 判断人员是否没有
+ if (empty($result->operatorId)) {
+ Logger::error('[FlowProcessor] 结果集中人员不存在');
+ }
+
+ return $result;
+ }
+
+ /**
+ * 处理流程执行结果
+ */
+ protected function handleResult(ProcessContext $context): void
+ {
+ if ($context->success) {
+ $repo = EctActionsRepository::new();
+
+ // 更新上一次的操作结束时间
+ $this->updateLastOperationEndTime($context, $repo);
+
+ // 数据库操作
+ if ($context->needDatabaseOperation) {
+ $this->saveToDatabase($context, $repo);
+ }
+
+ // 发送语音播报
+ Logger::debug('[FlowProcessor] 播报语音 voice={}', [$context->getFullVoice()]);
+ $this->sendVoice($context);
+
+ // WebSocket通知
+ if ($context->needWebSocketNotify) {
+ Logger::debug('[FlowProcessor] 发送 WebSocket 通知 endoscope={} step={}', [
+ $context->endoscopeName,
+ $context->currentStep,
+ ]);
+ $this->sendWebSocketNotification($context);
+ }
+ } else {
+ // 执行失败,播报错误信息
+ Logger::warn('流程失败,播报错误 voice={} error={}', [
+ $context->getFullVoice(),
+ $context->errorMessage,
+ ]);
+ $this->sendVoice($context);
+ }
+ }
+
+
+ /**
+ * 处理旧批次的结束时间更新
+ *
+ * @param ProcessContext $context 上下文
+ * @param EctActionsRepository $actionsRepo 操作记录仓储
+ */
+ protected function updateLastOperationEndTime(ProcessContext $context, EctActionsRepository $actionsRepo): void
+ {
+ $lastAction = $context->previousAction;
+ $oldActionStartTime = $lastAction->op_starttime;
+
+ // 仅当旧批次未结束且流程类型不一致时更新
+ if ($lastAction->op_endtime === null && $lastAction->process_name != $context->readerType) {
+ $oldBatchNo = $lastAction->op_batchno;
+ $oldActionEndTime = date('Y-m-d H:i:s');
+ $oldDuration = strtotime($oldActionEndTime) - strtotime($oldActionStartTime);
+
+ $actionsRepo->updateEndTime($oldBatchNo, $oldActionEndTime, $oldDuration);
+ Logger::debug(
+ "[更新] {$oldBatchNo} 批次中 {$lastAction->process_name} 流程的结束与耗时时间 {$oldActionEndTime} {$oldDuration} s"
+ );
+ }
+ }
+
+ /**
+ * 保存到数据库
+ */
+ protected function saveToDatabase(ProcessContext $context, EctActionsRepository $actionsRepo): void
+ {
+ $opuserType = $this->getOpusesType($context->operatorId);
+
+ Logger::debug('[FlowProcessor] 写库 op={} step={} batch={}', [
+ $context->dbOperation,
+ $context->currentStep,
+ $context->batchNo,
+ ]);
+
+ try {
+ $actionsRepo->saveFromContext($context, $opuserType);
+ } catch (\Exception $e) {
+ Logger::error('[FlowProcessor] 保存数据到数据库失败 context={} error={}', [
+ json_encode($context, JSON_UNESCAPED_UNICODE),
+ $e->getMessage(),
+ ]);
+ }
+ }
+
+ /**
+ * 发送语音播报
+ */
+ protected function sendVoice(string|ProcessContext $context): void
+ {
+ $voice = $context->getFullVoice();
+ if (is_string($context)) $voice = $context;
+ Logger::info("语音播报:$voice");
+ // 语音播报内容已准备就绪,外部服务按需接入:
+ // Windows: System.Speech / Linux: ekho / TTS服务 / 读卡器TCP指令
+ // 示例: VoiceService::speak($voice);
+ }
+
+ /**
+ * 发送WebSocket通知
+ */
+ protected function sendWebSocketNotification(ProcessContext $context): void
+ {
+ // 通过 Webman Push 发送通知:
+ // $api = new Api(...);
+ // $api->trigger('wash-update', 'message', [
+ // 'endoscope_id' => $context->endoscopeId,
+ // 'endoscope_name' => $context->endoscopeName,
+ // 'current_step' => $context->currentStep,
+ // 'process_type' => $context->processType,
+ // 'voice' => $context->voiceMessage,
+ // ]);
+ }
+
+ /**
+ * 获取操作员类型(从 User 表查询 role_id)
+ */
+ protected function getOpusesType(string $operatorId): int
+ {
+ if (empty($operatorId)) {
+ return 1; // 默认洗消工
+ }
+
+ try {
+ $user = UserRepository::new()->getUser((int)$operatorId);
+ return $user->role_id ?? 1;
+ } catch (\Throwable $e) {
+ Logger::debug('[FlowProcessor] 获取操作员类型失败,使用默认值 operatorId={} error={}', [
+ $operatorId,
+ $e->getMessage(),
+ ]);
+ return 1; // 默认洗消工
+ }
+ }
+
+ /**
+ * 更新配置
+ */
+ public function updateConfig(ProcessConfig $config): self
+ {
+ $this->engine->updateConfig($config);
+ return $this;
+ }
+
+ /**
+ * 创建处理器(静态工厂)
+ */
+ public static function create(?ProcessConfig $config = null): self
+ {
+ return new self($config);
+ }
+
+ /**
+ * 获取流程配置
+ */
+ public function getConfig(): ProcessConfig
+ {
+ return $this->engine->getConfig();
+ }
+}
diff --git a/app/flow/OperatorSessionManager.php b/app/flow/OperatorSessionManager.php
new file mode 100644
index 0000000..42f36b8
--- /dev/null
+++ b/app/flow/OperatorSessionManager.php
@@ -0,0 +1,131 @@
+ ['id'=>..., 'name'=>..., 'rfid'=>...]]
+ */
+ private array $sessions = [];
+
+ /**
+ * 人员刷卡记录缓存记录多久
+ */
+ public int $expire = 0;
+
+ /**
+ * 人员刷卡记录记录哪些读卡器的
+ * 设置为空则表示所有读卡器
+ */
+ public array $recordReaders = [];
+
+ private function __construct()
+ {
+ $this->expire = Config::getInstance()->openCardRecordCacheTime;
+ $this->recordReaders = Config::getInstance()->openCardRecordReaders;
+ }
+
+ public static function getInstance(): self
+ {
+ if (self::$instance === null) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ /**
+ * 重置单例(测试用)
+ */
+ public static function resetInstance(): void
+ {
+ self::$instance = null;
+ }
+
+ /**
+ * 记录某读卡器上的操作员信息
+ *
+ * @param string $readerId 读卡器ID
+ * @param string $userId 操作员ID
+ * @param string $userName 操作员姓名
+ * @param string $userRfid 操作员RFID
+ */
+ public function setOperator(string $readerId, string $userId, string $userName, string $userRfid): void
+ {
+ $this->sessions[$readerId] = [
+ 'id' => $userId,
+ 'name' => $userName,
+ 'rfid' => $userRfid,
+ 'time' => time(),
+ ];
+ }
+
+ /**
+ * 获取某读卡器上记录的操作员信息
+ *
+ * @param string $readerId 读卡器ID
+ * @return array|null ['id'=>..., 'name'=>..., 'rfid'=>...] 或 null(未登记)
+ */
+ public function getOperator(string $readerId, string $readerType): ?array
+ {
+ $cache = $this->sessions[$readerId] ?? null;
+ $time = $cache['time'];
+ Logger::debug("OperatorSessionManager::getOperator($readerId, $readerType)");
+
+ if (empty($cache)) return null;
+ if ($this->expire > 0 && isset($time) && (time() - $time) <= $this->expire) {
+ if (in_array($readerType, $this->recordReaders) && empty($this->recordReaders)) {
+ return $cache;
+ }
+ }
+ return $this->pop($readerId);
+ }
+
+ /**
+ * 判断某读卡器是否已有操作员登记
+ */
+ public function hasOperator(string $readerId): bool
+ {
+ return isset($this->sessions[$readerId]);
+ }
+
+ /**
+ * 清除某读卡器的操作员会话(内镜卡刷卡后立即调用)
+ */
+ public function clearOperator(string $readerId): void
+ {
+ unset($this->sessions[$readerId]);
+ }
+
+ /**
+ * 清除所有会话(测试用)
+ */
+ public function clearAll(): void
+ {
+ $this->sessions = [];
+ }
+
+ public function pop(string $readerId): ?array
+ {
+ $op = $this->getOperator($readerId);
+ $this->clearOperator($readerId);
+ return $op;
+ }
+}
diff --git a/app/flow/ProcessContext.php b/app/flow/ProcessContext.php
new file mode 100644
index 0000000..81890cb
--- /dev/null
+++ b/app/flow/ProcessContext.php
@@ -0,0 +1,737 @@
+previousAction;
+ }
+ }
+
+ /**
+ * 流程类型: 手工洗 / 机洗 / 测漏 / 存储 / 手工洗(晨洗)/ 机洗(晨洗)
+ */
+ public string $processType = '';
+
+ /**
+ * 批次号
+ */
+ public string $batchNo = '';
+
+ /**
+ * 操作开始时间
+ */
+ public string $actionStartTime = '';
+
+
+ /**
+ * @var int|null 操作时长(秒)
+ * 除非处理中有节点主动设置,否则一直是 null
+ */
+ public ?int $duration = null;
+
+ // ==================== 晨洗相关 ====================
+ /**
+ * 是否需要晨洗
+ */
+ public bool $needMorningWash = false;
+
+ /**
+ * 是否已完成晨洗
+ */
+ public bool $morningWashed = false;
+
+ /**
+ * 存储入库时间(用于晨洗判断)
+ */
+ public ?string $storageInTime = null;
+
+ /**
+ * 今天洗消记录数
+ */
+ public int $todayWashRecords = 0;
+
+ // ==================== 存储相关 ====================
+ /**
+ * 内镜是否在存储柜中
+ */
+ public bool $isInStorage = false;
+
+ /**
+ * 最后一次存储操作类型:入库/出库
+ */
+ public string $lastStorageAction = '';
+
+ /**
+ * 晨洗开始时间
+ */
+ public string $morningStartTime = '00:00:00';
+
+ // ==================== 步骤时间记录 ====================
+
+ /**
+ * 各步骤时长要求(从数据库获取)[步骤编码 => 秒数]
+ */
+ public array $stepDurations = [];
+
+ // ==================== 操作员信息 ====================
+ /**
+ * 操作员ID(人员卡)
+ */
+ public string $operatorId = '';
+
+ /**
+ * 操作员姓名
+ */
+ public string $operatorName = '';
+
+ /**
+ * 操作员RFID
+ */
+ public string $operatorRfid = '';
+
+ // ==================== 提醒标记 ====================
+ /**
+ * 是否需要测漏提醒
+ */
+ public bool $needLeakTestRemind = false;
+
+ /**
+ * 是否需要存储提醒
+ */
+ public bool $needStorageRemind = false;
+
+ /**
+ * 是否已测漏
+ */
+ public bool $leakTestDone = false;
+
+ /**
+ * 测漏结果
+ */
+ public string $leakTestResult = ''; // '正常' / '异常' / ''
+
+ // ==================== 语音输出 ====================
+ /**
+ * 语音播报内容
+ */
+ public string $voiceMessage = '';
+
+ // ==================== 执行结果 ====================
+ /**
+ * 流程执行结果
+ */
+ public bool $success = true {
+ get {
+ return $this->success;
+ }
+ }
+
+ /**
+ * 错误信息
+ */
+ public VoiceMessage $errorMessage = VoiceMessage::NONE;
+
+ // ==================== 原始数据 ====================
+ /**
+ * 原始刷卡数据(PacketContext)
+ */
+ public ?PacketContext $packetContext = null;
+
+ /**
+ * 原始刷卡数据数组(兼容旧代码)
+ */
+ public array $rawData = [];
+
+ // ==================== 数据库相关标记 ====================
+ /**
+ * 是否需要操作数据库
+ */
+ public bool $needDatabaseOperation = false;
+
+ /**
+ * 数据库操作类型 insert / update
+ * DbOperationType
+ */
+ public array $dbOperation = [] {
+ get {
+ return $this->dbOperation;
+ }
+
+ set(DbOperationType|array $value) {
+ if (is_array($value)) {
+ $this->dbOperation = $value;
+ } else {
+ $this->dbOperation[] = $value;
+ }
+ }
+ }
+
+ /**
+ * 是否需要发送WebSocket通知
+ */
+ public bool $needWebSocketNotify = false;
+
+ /**
+ * 这张卡是否是人员卡(而非内镜卡)
+ * true 时 FlowProcessor 不走流程链,直接存储操作员信息
+ */
+ public bool $isOperatorCard = false;
+
+
+ public ?ProcessConfig $engineConfig;
+
+ /**
+ * 期望下一步刷卡提示
+ * 当节点识别到读卡器类型匹配但流程步骤不符时,由节点自行写入此字段
+ * ProcessEngine 无节点命中时直接读取此字段作为错误语音提示
+ */
+ public VoiceMessage $expectedNextStep = VoiceMessage::NONE {
+ get {
+ return $this->expectedNextStep;
+ }
+ set {
+ // 获取调用这个方法的类名
+ $callerClass = debug_backtrace()[1]['class'] ?? '';
+ $callerClass = array_reverse(explode('\\', $callerClass)) [0];
+ Logger::debug("[{}] 设置节点预期: {}", [$callerClass, $value]);
+ $this->expectedNextStep = $value;
+ }
+ }
+
+ /**
+ * @var int 设置后从当前节点跳过 skipNodeCount 个节点
+ */
+ public int $skipNodeCount = 0;
+
+ public function __construct()
+ {
+ $this->config = Config::getInstance();
+ }
+
+ // ==================== 工具方法 ====================
+
+ /**
+ * 创建上下文实例
+ */
+ public static function create(array $data = []): self
+ {
+ $context = new self();
+ foreach ($data as $key => $value) {
+ if (property_exists($context, $key) && $value !== null) {
+ $context->$key = $value;
+ }
+ }
+ return $context;
+ }
+
+ /**
+ * 从 PacketContext 创建流程上下文,并自动从数据库补全内镜、读卡器及历史记录信息
+ *
+ * @param PacketContext $packetContext 数据包上下文
+ * @param array $additionalData 额外补充/覆盖的数据
+ * @return self
+ */
+ public static function fromPacketContext(PacketContext $packetContext, array $additionalData = []): self
+ {
+ $context = new self();
+ $context->packetContext = $packetContext;
+ $context->engineConfig = $additionalData['engineConfig'] ?? null;
+
+
+ if ($context->engineConfig !== null) {
+ $context->morningStartTime = $context->engineConfig->getMorningWashConfig()->morningStartTime;
+ }
+
+ // 1. 初始化基础信息
+ $context->cardNo = $packetContext->packet->card ?? '';
+ $context->readerNo = $packetContext->packet->reader ?? '';
+ $context->rawData = $packetContext->packet->toArray();
+
+ // 2. 加载内镜/操作员信息
+ $context->loadEndoscopeOrOperatorInfo();
+
+ // 3. 加载读卡器信息
+ $context->loadReaderInfo();
+
+ // 4. 加载内镜操作记录相关信息(仅当内镜ID存在时执行)
+ if (!empty($context->endoscopeId)) {
+ $context->loadEndoscopeActionInfo();
+ $context->loadStorageStatus();
+ }
+
+ // 5. 合并额外数据(外部可覆盖以上任意字段)
+ foreach ($additionalData as $key => $value) if (property_exists($context, $key)) {
+ $context->$key = $value;
+ }
+
+ Logger::debug("从 PacketContext 创建 ProcessContext 流程上下文");
+ return $context;
+ }
+
+ /**
+ * 加载内镜或操作员信息
+ */
+ private function loadEndoscopeOrOperatorInfo(): void
+ {
+ if (empty($this->cardNo)) {
+ return;
+ }
+
+ // 优先查询内镜信息
+ $endoscope = EndoscopeRepository::new()->findByCardNo($this->cardNo);
+ if ($endoscope !== null) {
+ $this->endoscopeId = (string)$endoscope->endoscope_id;
+ $this->endoscopeName = (string)$endoscope->endoscope_name;
+ $this->endoscopeType = (string)$endoscope->endoscope_type;
+ return;
+ }
+
+ // 内镜无记录则查询人员卡信息
+ try {
+ $user = UserRepository::new()->findByRfid($this->cardNo);
+ if ($user !== null) {
+ $this->isOperatorCard = true;
+ $this->operatorId = (string)$user->user_id;
+ $this->operatorName = (string)$user->user_name;
+ $this->operatorRfid = (string)$user->user_rfid;
+ }
+ } catch (Exception $e) {
+ Logger::error("[ProcessContext] 查询人员卡信息出错: {}", [$e->getMessage()]);
+ }
+ }
+
+ /**
+ * 加载读卡器信息
+ */
+ private function loadReaderInfo(): void
+ {
+ if (empty($this->readerNo)) {
+ return;
+ }
+
+ $readerInfo = ReaderRepository::new()->findReaderInfo($this->readerNo);
+ if ($readerInfo !== null) {
+ $this->readerId = $readerInfo['readerId'];
+ $this->readerType = $readerInfo['readerType'];
+ }
+ }
+
+ /**
+ * 加载内镜操作记录相关信息
+ */
+ private function loadEndoscopeActionInfo(): void
+ {
+ $actionsRepo = EctActionsRepository::new();
+
+ // 查询最后一条操作记录
+ $lastAction = $actionsRepo->findLastAction($this->endoscopeId, [0, 7, 8]);
+ if (empty($lastAction)) {
+ return;
+ }
+ $lastStepStartTime = $lastAction->op_starttime;
+ $this->previousAction = $lastAction;
+ $this->duration = time() - strtotime($lastStepStartTime); // 计算操作时长(秒)
+ $this->handleLastAction($lastAction, $actionsRepo);
+
+ // 查询今日洗消记录数(晨洗判断)
+ $this->todayWashRecords = $actionsRepo->countTodayActions($this->endoscopeId, $this->morningStartTime, [0, 7, 8]);
+ }
+
+ /**
+ * 加载内镜存储状态
+ */
+ private function loadStorageStatus(): void
+ {
+ $actionsRepo = EctActionsRepository::new();
+
+ // 查询最后一次存储操作记录
+ $lastStorageAction = $actionsRepo->findLastStorageAction($this->endoscopeId);
+ if ($lastStorageAction !== null) {
+ $this->isInStorage = ($lastStorageAction['process_name'] === '内镜放入');
+ $this->lastStorageAction = $lastStorageAction['process_name'];
+ if ($this->isInStorage) {
+ $this->storageInTime = $lastStorageAction['op_starttime'];
+ }
+ }
+
+ // 查询最后一次存储入库时间(义乌模式晨洗判断)
+ $storageTime = $actionsRepo->findLastStorageTime($this->endoscopeId);
+ if ($storageTime !== null) {
+ $this->storageInTime = $storageTime;
+ }
+ }
+
+ /**
+ * 处理最后一条操作记录的核心逻辑
+ *
+ * @param EctActions $lastAction 最后一条操作记录
+ * @param EctActionsRepository $actionsRepo 操作记录仓储
+ */
+ private function handleLastAction(EctActions $lastAction, EctActionsRepository $actionsRepo): void
+ {
+ // 设置基础操作信息
+ $this->currentStep = (string)$lastAction->process_name;
+ $this->actionStartTime = date('Y-m-d H:i:s');
+
+ // 处理批次号逻辑
+ // 结束步骤:创建新批次;非结束步骤:使用历史批次并处理旧批次
+ if ($this->currentStep === '结束') {
+ $newBatchNo = $this->getOrCreateBatchNo(true);
+ $this->batchNo = $newBatchNo;
+ } else {
+ $this->batchNo = (string)$lastAction->op_batchno;
+ $this->processType = (string)$lastAction->action_type_name;
+ }
+
+ // 加载批次操作员信息
+ if ($this->currentStep !== '结束' && !empty($this->batchNo)) {
+ $this->loadBatchOperatorInfo($actionsRepo);
+ }
+ }
+
+
+ /**
+ * 加载批次对应的操作员信息
+ *
+ * @param EctActionsRepository $actionsRepo 操作记录仓储
+ */
+ private function loadBatchOperatorInfo(EctActionsRepository $actionsRepo): void
+ {
+ $operator = $actionsRepo->findOperatorByBatchNo($this->batchNo);
+ if ($operator !== null) {
+ $this->operatorId = $operator['id'];
+ $this->operatorName = $operator['name'];
+ $this->operatorRfid = $operator['rfid'];
+ }
+ }
+
+
+ // ==================== 非静态方法 ====================
+
+ /**
+ * 设置错误状态。
+ * 注:同时会将 success 字段设置为 false
+ * 注:设置后会阻断自动流程的 next 步骤执行
+ */
+ public function setError(VoiceMessage $message): self
+ {
+ $this->errorMessage = $message;
+ $this->success = false;
+ return $this;
+ }
+
+ /**
+ * 设置错误状态。
+ * 注:同时会将 success 字段设置为 false
+ * 注:设置后会阻断自动流程的 next 步骤执行
+ * @param string $message
+ * @return $this
+ */
+ public function setCustomError(string $message): self
+ {
+ $this->errorMessage = VoiceMessage::CUSTOM;
+ $this->voiceMessage = $message;
+ $this->success = false;
+ return $this;
+ }
+
+ /**
+ * 设置语音消息
+ */
+ public function setVoice(string|VoiceMessage $message): self
+ {
+ $illegalCaller = false;
+ $targetIndex = 2; // 目标层级索引
+ $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 6);
+ foreach ($backtrace as $index => $trace) {
+ if ($index === $targetIndex) {
+ $caller = $trace['class'] ?? '';
+ if (!empty($caller) && $caller === 'VoiceGenerationStrategy') $illegalCaller = true;
+ break;
+ }
+ }
+ if ($illegalCaller) Logger::warn(
+ "不应该在 VoiceGenerationStrategy 之外的地方调用 setVoice 方法",
+ new IllegalUsageException("Not allowed to call setVoice method outside of VoiceGenerationStrategy")
+ );
+
+
+ if ($message instanceof VoiceMessage) {
+ $this->voiceMessage = $message->value;
+ } else {
+ $this->voiceMessage = $message;
+ }
+ return $this;
+ }
+
+ /**
+ * 添加语音前缀(内镜名称等)
+ */
+ public function prependVoice(string $prefix): self
+ {
+ $this->voiceMessage = $prefix . $this->voiceMessage;
+ return $this;
+ }
+
+ /**
+ * 获取完整语音(包含内镜名称)
+ */
+ public function getFullVoice(): string
+ {
+// $name = str_replace(['北院', '南院', '电子'], '', $this->endoscopeName);
+// return $this->endoscopeName . $this->voiceMessage;
+ $message = $this->voiceMessage;
+ if (empty($message)) $message = $this->errorMessage->value;
+ return $message;
+ }
+
+ // ==================== 批次号管理(分布式一致性) ====================
+
+ /**
+ * 获取或创建批次号
+ *
+ * 分布式场景下的批次号一致性保证:
+ * 1. 首先尝试从数据库获取该内镜当前未完成的流程批次号
+ * 2. 如果没有未完成的流程,生成新的批次号
+ * 3. 批次号格式:年月日时分秒 + 内镜ID + 随机数
+ *
+ * @param bool $forceNew 强制生成新批次号(用于开始新流程)
+ * @return string 批次号
+ */
+ public function getOrCreateBatchNo(bool $forceNew = false): string
+ {
+ // 如果已有批次号且不强制新建,直接返回
+ if (!$forceNew && !empty($this->batchNo)) {
+ return $this->batchNo;
+ }
+
+ // 从数据库查询最大的批次号
+ $existingBatchNo = null;
+ if (!empty($this->endoscopeId)) $existingBatchNo = EctActionsRepository::new()->findTodayActiveBatchNo($this->config->machineId);
+
+ return $this->generateBatchNo($existingBatchNo);
+ }
+
+ /**
+ * 生成批次号
+ *
+ * 批次号格式:YYYYMMDD + 机器ID(两位) + 顺序递增(4位)
+ *
+ * @param string|null $existingBatchNo 已存在的最大批次号(无则传null)
+ * @return string 批次号
+ */
+ public function generateBatchNo(?string $existingBatchNo = null): string
+ {
+ // 1. 生成日期部分(仅YYYYMMDD,符合需求格式)
+ $datePart = date('Ymd');
+
+ // 2. 初始化序号为1(默认初始值)
+ $sequence = 1;
+
+ // 3. 如果存在已有批次号,解析并递增序号
+ if (!empty($existingBatchNo)) {
+ // 截取批次号的日期部分(前8位)
+ $existingDatePart = substr($existingBatchNo, 0, 8);
+ // 截取批次号的序号部分(后4位)
+ $existingSequence = substr($existingBatchNo, 10, 4);
+
+ // 如果已有批次号的日期和今天一致,序号递增
+ if ($existingDatePart === $datePart && is_numeric($existingSequence)) {
+ $sequence = (int)$existingSequence + 1;
+ }
+ // 如果日期不一致,序号重置为1(无需额外处理,默认就是1)
+ }
+
+ // 4. 将序号补零为4位(如1→0001,10→0010,100→0100)
+ $sequencePart = str_pad($sequence, 4, '0', STR_PAD_LEFT);
+
+ // 5. 拼接并返回最终批次号
+ return $datePart . $this->config->machineId . $sequencePart;
+ }
+
+ /**
+ * 设置批次号
+ */
+ public function setBatchNo(string $batchNo): self
+ {
+ $this->batchNo = $batchNo;
+ return $this;
+ }
+
+ /**
+ * @param string $batchNo
+ * @return array
+ */
+ public static function parseBatchNo(string $batchNo): array
+ {
+ // 批次号格式:YYYYMMDD + 机器ID(两位) + 顺序递增(4位)
+ $datePart = substr($batchNo, 0, 8);
+ $machineId = substr($batchNo, 8, 2);
+ $sequencePart = substr($batchNo, 10, 4);
+
+ return [
+ 'date' => $datePart,
+ 'machineId' => $machineId,
+ 'sequence' => $sequencePart,
+ ];
+ }
+
+ // ==================== 步骤时间操作方法 ====================
+
+ /**
+ * 获取步骤时长要求(秒)
+ * 优先级:上下文已缓存 > 数据库(按 processType 精确匹配)> 内置 fallback
+ */
+ public function getStepDuration(string $stepCode): int
+ {
+ // 优先使用上下文中已缓存的时长
+ if (isset($this->stepDurations[$stepCode])) {
+ return $this->stepDurations[$stepCode];
+ }
+
+ // 从数据库按流程类型精确查询
+ if (!empty($this->processType)) {
+ $dbValue = ProcessDurationRepository::new()
+ ->getDurationByProcessTypeAndName($this->processType, $stepCode);
+ if ($dbValue !== null) {
+ // 写入缓存,避免后续重复查询
+ $this->stepDurations[$stepCode] = $dbValue;
+ return $dbValue;
+ }
+ }
+
+ // 内置 fallback(与 ect_meta_process 表数据对齐)
+ $defaults = [
+ '清洗' => 120, // 手工洗/机洗最短 60~120s,取保守值
+ '漂洗' => 60, // 60s
+ '消毒' => 300, // 300s
+ '终末漂洗' => 120, // 120s
+ '干燥' => 30, // 30s
+ '机洗' => 360, // 机洗(晨洗)360s
+ ];
+
+ return $defaults[$stepCode] ?? 0;
+ }
+
+ /**
+ * 设置步骤时长要求
+ */
+ public function setStepDuration(string $stepCode, int $seconds): self
+ {
+ $this->stepDurations[$stepCode] = $seconds;
+ return $this;
+ }
+
+
+
+ // ==================== 流程状态检查 ====================
+
+ /**
+ * 检查是否可以开始新流程
+ */
+ public function canStartNewProcess(): bool
+ {
+ // 当前步骤为空、结束、内镜取出、测漏正常时可以开始新流程
+ $validSteps = ['', '结束', '内镜取出', '测漏正常', '测漏异常'];
+ return in_array($this->currentStep, $validSteps);
+ }
+
+ /**
+ * 检查是否已完成清洗流程
+ */
+ public function isWashProcessCompleted(): bool
+ {
+ return $this->currentStep === '结束';
+ }
+
+ public function hasOperator(): bool
+ {
+ return !empty($this->operatorRfid) && !empty($this->operatorId) && !empty($this->operatorName);
+ }
+}
diff --git a/app/flow/ProcessEngine.php b/app/flow/ProcessEngine.php
new file mode 100644
index 0000000..5523360
--- /dev/null
+++ b/app/flow/ProcessEngine.php
@@ -0,0 +1,378 @@
+config = $config ?? new ProcessConfig();
+ $this->initStrategies();
+ $this->buildChain();
+ }
+
+ /**
+ * 初始化策略
+ */
+ protected function initStrategies(): void
+ {
+ Logger::debug("[ProcessEngine] 初始化策略...");
+ // 晨洗判断策略
+ $this->strategies['morning_wash'] = new MorningWashStrategy(
+ $this->config->getMorningWashConfig()
+ );
+
+ // 时间验证策略
+ $this->strategies['time_validation'] = new TimeValidationStrategy(
+ $this->config->getTimeValidationConfig()
+ );
+
+ // 语音生成策略
+ $this->strategies['voice_generation'] = new VoiceGenerationStrategy(
+ $this->config->getVoiceTemplatesConfig()
+ );
+ Logger::debug("[ProcessEngine] 策略初始化完成");
+ }
+
+ /**
+ * 构建流程链
+ */
+ protected function buildChain(): void
+ {
+ Logger::debug("[ProcessEngine] 构建流程链...");
+ $steps = $this->config->getSteps();
+ $prevNode = null;
+
+ foreach ($steps as $step) {
+ $node = $this->createNode($step->class);
+ Logger::debug("[ProcessEngine] 创建节点 {}", [$step->class]);
+
+ if ($node === null) {
+ Logger::warning("[ProcessEngine] 无法创建节点 {}", [$step->class]);
+ continue;
+ }
+
+ // 设置节点启用状态
+ $node->setEnabled($step->enabled);
+ Logger::debug(" [{}] 启用状态 {}", [$node->getName(), $step->enabled]);
+
+ // 添加策略
+ $this->attachStrategies($node, $step->code);
+ Logger::debug("[{}] 策略添加完成", [$node->getName()]);
+
+ // 保存到映射表
+ $this->nodeMap[$step->code] = $node;
+ Logger::debug("[{}] 保存到映射表", [$node->getName()]);
+
+ // 构建流程链
+ if ($prevNode === null) {
+ $this->chainHead = $node;
+ } else {
+ $prevNode->setNext($node);
+ }
+ // 添加配置
+ $node->setConfig($step);
+ Logger::debug("[{}] 设置原始配置: {}", [$node->getName(), json_encode($step)]);
+
+ $prevNode = $node;
+ Logger::debug("[{}] 节点创建完成", [$node->getName()]);
+ }
+ Logger::debug("[ProcessEngine] 流程链构建完成");
+ }
+
+ /**
+ * 创建节点实例
+ */
+ protected function createNode(string $className): ?ProcessNodeInterface
+ {
+ // 添加命名空间
+ if (!str_contains($className, '\\')) {
+ $className = $this->nodeNamespace . $className;
+ }
+
+ if (!class_exists($className)) {
+ return null;
+ }
+
+ return new $className();
+ }
+
+ /**
+ * 为节点附加策略
+ */
+ protected function attachStrategies(ProcessNodeInterface $node, string $stepCode): void
+ {
+ // 晨洗节点添加晨洗判断策略
+ if ($stepCode === MorningWashNode::getName()) {
+ $node->addStrategy($this->strategies['morning_wash']);
+ Logger::debug("[{}] 添加 MorningWashStrategy", [$node->getName()]);
+ }
+
+ // 有时间要求的步骤添加时间验证策略
+ $timeSteps = ['清洗', '漂洗', '消毒', '终末漂洗', '干燥'];
+ if (in_array($stepCode, $timeSteps)) {
+ $node->addStrategy($this->strategies['time_validation']);
+ Logger::debug("[{}] 添加 TimeValidationStrategy", [$node->getName()]);
+ }
+
+ // 除了晨洗所有节点都添加语音生成策略
+ if ($stepCode !== MorningWashNode::getName()) {
+ $node->addStrategy($this->strategies['voice_generation']);
+ Logger::debug("[{}] 添加 VoiceGenerationStrategy", [$node->getName()]);
+ }
+
+ // 存储节点添加语音生成策略
+ if ($stepCode === '存储') {
+ $node->addStrategy($this->strategies['voice_generation']);
+ Logger::debug("[{}] 存储节点配置完成", [$node->getName()]);
+ }
+ }
+
+ /**
+ * 执行流程
+ */
+ public function execute(ProcessContext $context): ProcessContext
+ {
+ if ($this->chainHead === null) {
+ return $context->setError(VoiceMessage::PROCESS_CHAIN_NOT_INITIALIZED);
+ }
+
+ Logger::debug('[ProcessEngine] 开始执行流程链 readerType={} currentStep={} endoscope={}', [
+ $context->readerType,
+ $context->currentStep ?: '(空)',
+ $context->endoscopeName ?: $context->endoscopeId ?: '-',
+ ]);
+
+ $result = $this->chainHead->handle($context);
+
+ Logger::debug('[ProcessEngine] 流程执行完成 endoscope={} step={} success={} error={}', [
+ $result->endoscopeName,
+ $result->currentStep,
+ $result->success ? 'true' : 'false',
+ $result->errorMessage->value ?: '-',
+ ]);
+
+ return $result;
+ }
+
+ /**
+ * 快速执行(从数组创建上下文)
+ */
+ public function executeFromArray(array $data): ProcessContext
+ {
+ $context = ProcessContext::create($data);
+ return $this->execute($context);
+ }
+
+ /**
+ * 获取配置
+ */
+ public function getConfig(): ProcessConfig
+ {
+ return $this->config;
+ }
+
+ /**
+ * 更新配置并重建流程链
+ */
+ public function updateConfig(ProcessConfig $config): self
+ {
+ $this->config = $config;
+ $this->nodeMap = [];
+ $this->chainHead = null;
+ $this->initStrategies();
+ $this->buildChain();
+ return $this;
+ }
+
+ /**
+ * 获取节点
+ */
+ public function getNode(string $code): ?ProcessNodeInterface
+ {
+ return $this->nodeMap[$code] ?? null;
+ }
+
+ /**
+ * 启用节点
+ */
+ public function enableNode(string $code): self
+ {
+ $this->config->enableStep($code);
+
+ if (isset($this->nodeMap[$code])) {
+ $this->nodeMap[$code]->setEnabled(true);
+ }
+
+ return $this;
+ }
+
+ /**
+ * 禁用节点
+ */
+ public function disableNode(string $code): self
+ {
+ $this->config->skipStep($code);
+
+ if (isset($this->nodeMap[$code])) {
+ $this->nodeMap[$code]->setEnabled(false);
+ }
+
+ return $this;
+ }
+
+ /**
+ * 设置节点启用状态
+ */
+ public function setNodeEnabled(string $code, bool $enabled): self
+ {
+ return $enabled ? $this->enableNode($code) : $this->disableNode($code);
+ }
+
+ /**
+ * 获取所有节点
+ */
+ public function getNodes(): array
+ {
+ return $this->nodeMap;
+ }
+
+ /**
+ * 获取启用的节点
+ */
+ public function getEnabledNodes(): array
+ {
+ return array_filter($this->nodeMap, function ($node) {
+ return $node->isEnabled();
+ });
+ }
+
+ /**
+ * 添加自定义策略
+ */
+ public function addStrategy(string $name, $strategy): self
+ {
+ $this->strategies[$name] = $strategy;
+ return $this;
+ }
+
+ /**
+ * 获取策略
+ */
+ public function getStrategy(string $name)
+ {
+ return $this->strategies[$name] ?? null;
+ }
+
+ /**
+ * 设置步骤自定义语音
+ */
+ public function setStepVoice(string $stepCode, string $voice): self
+ {
+ $this->config->setStepVoice($stepCode, $voice);
+
+ // 更新语音生成策略
+ if (isset($this->strategies['voice_generation'])) {
+ $this->strategies['voice_generation']->setStepVoice($stepCode, $voice);
+ }
+
+ return $this;
+ }
+
+ /**
+ * 设置晨洗模式
+ */
+ public function setMorningWashMode(string $mode): self
+ {
+ $this->config->setMorningWashMode($mode);
+
+ // 更新晨洗策略
+ if (isset($this->strategies['morning_wash'])) {
+ $this->strategies['morning_wash'] = new MorningWashStrategy(
+ $this->config->getMorningWashConfig()
+ );
+ }
+
+ return $this;
+ }
+
+ /**
+ * 创建引擎(静态工厂方法)
+ */
+ public static function create(?ProcessConfig $config = null): self
+ {
+ return new self($config);
+ }
+
+ /**
+ * 创建标准流程引擎
+ */
+ public static function createStandard(): self
+ {
+ return new self(ProcessConfig::createStandard());
+ }
+
+ /**
+ * 创建无晨洗流程引擎
+ */
+ public static function createNoMorningWash(): self
+ {
+ return new self(ProcessConfig::createNoMorningWash());
+ }
+
+ /**
+ * 创建简化流程引擎
+ */
+ public static function createSimple(): self
+ {
+ return new self(ProcessConfig::createSimple());
+ }
+
+ /**
+ * 创建机洗流程引擎
+ */
+ public static function createMachineWash(): self
+ {
+ return new self(ProcessConfig::createMachineWash());
+ }
+}
diff --git a/app/flow/VoiceMessage.php b/app/flow/VoiceMessage.php
new file mode 100644
index 0000000..14bf4dd
--- /dev/null
+++ b/app/flow/VoiceMessage.php
@@ -0,0 +1,62 @@
+ None, 'storage_time' => StorageTime, 'daily_first' => DailyFirst
+ */
+ public static function from_name(string $name): ?self
+ {
+ return match (strtolower($name)) {
+ 'none' => self::None,
+ 'all' => self::All,
+ 'storage_time' => self::StorageTime,
+ 'daily_first' => self::DailyFirst,
+ 'specific_types' => self::SpecificTypes,
+ default => null,
+ };
+ }
+}
diff --git a/app/flow/config/MorningWashConfig.php b/app/flow/config/MorningWashConfig.php
new file mode 100644
index 0000000..d9a18d5
--- /dev/null
+++ b/app/flow/config/MorningWashConfig.php
@@ -0,0 +1,88 @@
+ $this->mode;
+ set => $this->mode = $value;
+ }
+
+ public int $storageThreshold {
+ get => $this->storageThreshold;
+ set => $this->storageThreshold = $value;
+ }
+
+ public string $morningStartTime {
+ get => $this->morningStartTime;
+ set => $this->morningStartTime = $value;
+ }
+
+ /**
+ * 扩展字段:存储除已知字段(mode / storage_threshold / morning_start_time)之外的原始字段
+ * 以及业务侧自定义扩展字段(如 specific_types 等)
+ */
+ public array $expand {
+ get => $this->expand;
+ }
+
+ public function __construct(
+ MorningMode $mode = MorningMode::DailyFirst,
+ int $storageThreshold = 4,
+ string $morningStartTime = '00:00:00',
+ array $expand = []
+ ) {
+ $this->mode = $mode;
+ $this->storageThreshold = $storageThreshold;
+ $this->morningStartTime = $morningStartTime;
+ $this->expand = $expand;
+ }
+
+ /**
+ * 从数组创建
+ * 已知字段直接映射到具体属性,其余字段(含原始字段)存入 expand
+ */
+ public static function create(array $config = []): self
+ {
+ // mode 支持字符串(兼容旧配置文件)
+ $mode = $config['mode'] ?? MorningMode::DailyFirst;
+ if (is_string($mode)) {
+ $mode = MorningMode::from_name($mode) ?? MorningMode::DailyFirst;
+ }
+
+ $known = ['mode', 'storage_threshold', 'morning_start_time'];
+ $expand = array_diff_key($config, array_flip($known));
+ // 把原始 config 整体也保留在 expand['_raw'] 里,便于下游无损访问
+ $expand['_raw'] = $config;
+
+ $instance = new self(
+ $mode,
+ $config['storage_threshold'] ?? 4,
+ $config['morning_start_time'] ?? '00:00:00',
+ $expand
+ );
+ $instance->log("加载[morning_wash]晨洗配置,来源: {}", [empty($config) ? "默认配置" : "自定义配置"]);
+ return $instance;
+ }
+
+ /**
+ * 获取扩展字段值(如 specific_types)
+ */
+ public function getExpand(string $key, mixed $default = null): mixed
+ {
+ return $this->expand[$key] ?? $default;
+ }
+
+ public function toArray(): array
+ {
+ return array_merge([
+ 'mode' => $this->mode->name,
+ 'storage_threshold' => $this->storageThreshold,
+ 'morning_start_time' => $this->morningStartTime,
+ ], array_diff_key($this->expand, ['_raw' => null]));
+ }
+}
diff --git a/app/flow/config/ProcessConfig.php b/app/flow/config/ProcessConfig.php
new file mode 100644
index 0000000..0ec5fc9
--- /dev/null
+++ b/app/flow/config/ProcessConfig.php
@@ -0,0 +1,289 @@
+log("ProcessConfig 初始化中...");
+
+ $this->stepsConfig = StepsConfig::create($config['steps'] ?? [], $config['override_steps'] ?? true);
+ $this->morningWashConfig = MorningWashConfig::create($config['morning_wash'] ?? []);
+ $this->timeValidationConfig = TimeValidationConfig::create($config['time_validation'] ?? []);
+ $this->voiceTemplatesConfig = VoiceTemplatesConfig::create($config['voice_templates'] ?? []);
+
+ // node_status 覆盖 steps 中的 enabled
+ foreach (($config['node_status'] ?? []) as $code => $enabled) {
+ $this->stepsConfig->setEnabled($code, (bool)$enabled);
+ }
+
+ $this->log("ProcessConfig 初始化完成");
+ }
+
+ // ---- 步骤配置 ----
+
+ public function getStepsConfig(): StepsConfig
+ {
+ return $this->stepsConfig;
+ }
+
+ /**
+ * 获取所有步骤(StepConfig[])—— 兼容 ProcessEngine 调用
+ */
+ public function getSteps(): array
+ {
+ return $this->stepsConfig->all();
+ }
+
+ /**
+ * 获取启用的步骤
+ */
+ public function getEnabledSteps(): array
+ {
+ return $this->stepsConfig->enabled();
+ }
+
+ /**
+ * 获取步骤配置
+ */
+ public function getStep(string $code): ?StepConfig
+ {
+ return $this->stepsConfig->find($code);
+ }
+
+ /**
+ * 添加步骤
+ */
+ public function addStep(string $code, string $class, bool $enabled = true, array $expand = []): self
+ {
+ $this->stepsConfig->add($code, $class, $enabled, $expand);
+ return $this;
+ }
+
+ /**
+ * 移除步骤
+ */
+ public function removeStep(string $code): self
+ {
+ $this->stepsConfig->remove($code);
+ return $this;
+ }
+
+ /**
+ * 设置节点启用状态
+ */
+ public function setNodeEnabled(string $code, bool $enabled): self
+ {
+ $this->stepsConfig->setEnabled($code, $enabled);
+ return $this;
+ }
+
+ /**
+ * 检查节点是否启用
+ */
+ public function isNodeEnabled(string $code): bool
+ {
+ return $this->stepsConfig->find($code)?->enabled ?? true;
+ }
+
+ /**
+ * 跳过某个步骤(禁用节点)
+ */
+ public function skipStep(string $code): self
+ {
+ return $this->setNodeEnabled($code, false);
+ }
+
+ /**
+ * 启用某个步骤
+ */
+ public function enableStep(string $code): self
+ {
+ return $this->setNodeEnabled($code, true);
+ }
+
+ // ---- 晨洗配置 ----
+
+ public function getMorningWashConfig(): MorningWashConfig
+ {
+ return $this->morningWashConfig;
+ }
+
+ public function setMorningWashConfig(MorningWashConfig $config): self
+ {
+ $this->morningWashConfig = $config;
+ return $this;
+ }
+
+ /**
+ * 快捷设置晨洗模式(支持字符串或枚举)
+ */
+ public function setMorningWashMode(string|MorningMode $mode): self
+ {
+ if (is_string($mode)) {
+ $mode = MorningMode::from_name($mode) ?? MorningMode::DailyFirst;
+ }
+ $this->morningWashConfig->mode = $mode;
+ return $this;
+ }
+
+ // ---- 时间验证配置 ----
+
+ public function getTimeValidationConfig(): TimeValidationConfig
+ {
+ return $this->timeValidationConfig;
+ }
+
+ public function setTimeValidationConfig(TimeValidationConfig $config): self
+ {
+ $this->timeValidationConfig = $config;
+ return $this;
+ }
+
+ /**
+ * 设置步骤时长(秒)
+ */
+ public function setStepDuration(string $stepCode, int $seconds): self
+ {
+ $this->timeValidationConfig->setDuration($stepCode, $seconds);
+ return $this;
+ }
+
+ // ---- 语音模板配置 ----
+
+ public function getVoiceTemplatesConfig(): VoiceTemplatesConfig
+ {
+ return $this->voiceTemplatesConfig;
+ }
+
+ public function setVoiceTemplatesConfig(VoiceTemplatesConfig $config): self
+ {
+ $this->voiceTemplatesConfig = $config;
+ return $this;
+ }
+
+ /**
+ * 设置指定步骤的自定义语音
+ */
+ public function setStepVoice(string $templateKey, string $stepCode, string $voice): self
+ {
+ $this->voiceTemplatesConfig->setStepVoice($templateKey, $stepCode, $voice);
+ return $this;
+ }
+
+ // ---- 序列化 ----
+
+ public function toArray(): array
+ {
+ return [
+ 'steps' => $this->stepsConfig->toArray(),
+ 'morning_wash' => $this->morningWashConfig->toArray(),
+ 'time_validation' => $this->timeValidationConfig->toArray(),
+ 'voice_templates' => $this->voiceTemplatesConfig->toArray(),
+ ];
+ }
+
+ public function saveToFile(string $filePath): bool
+ {
+ $content = "toArray(), true) . ";\n";
+ return file_put_contents($filePath, $content) !== false;
+ }
+
+ // ---- 静态工厂 ----
+
+ public static function fromArray(array $config): self
+ {
+ return new self($config);
+ }
+
+ public static function fromFile(string $filePath): self
+ {
+ if (!file_exists($filePath)) {
+ return new self();
+ }
+ $config = require $filePath;
+ return new self($config);
+ }
+
+ public static function createStandard(): self
+ {
+ return new self();
+ }
+
+ public static function createNoMorningWash(): self
+ {
+ $config = new self();
+ $config->setMorningWashMode(MorningMode::None);
+ $config->skipStep('晨洗');
+ return $config;
+ }
+
+ public static function createSimple(): self
+ {
+ $config = new self();
+ $config->setMorningWashMode(MorningMode::None);
+ $config->skipStep('漂洗');
+ $config->skipStep('消毒');
+ $config->skipStep('终末漂洗');
+ $config->skipStep('干燥');
+ return $config;
+ }
+
+ public static function createMachineWash(): self
+ {
+ $config = new self();
+ $config->setMorningWashMode(MorningMode::None);
+ $config->skipStep('漂洗');
+ $config->skipStep('消毒');
+ return $config;
+ }
+
+ public static function createNoDry(): self
+ {
+ $config = new self();
+ $config->skipStep('干燥');
+ return $config;
+ }
+
+ public static function createDryOnly(): self
+ {
+ $config = new self();
+ $config->setMorningWashMode(MorningMode::None);
+ $config->skipStep('晨洗');
+ $config->skipStep('清洗');
+ $config->skipStep('漂洗');
+ $config->skipStep('消毒');
+ $config->skipStep('终末漂洗');
+ return $config;
+ }
+}
diff --git a/app/flow/config/StepConfig.php b/app/flow/config/StepConfig.php
new file mode 100644
index 0000000..13c6673
--- /dev/null
+++ b/app/flow/config/StepConfig.php
@@ -0,0 +1,69 @@
+ $this->code;
+ }
+
+ /**
+ * 节点类名(不含命名空间)
+ */
+ public string $class {
+ get => $this->class;
+ }
+
+ /**
+ * 是否启用
+ */
+ public bool $enabled {
+ get => $this->enabled;
+ set => $this->enabled = $value;
+ }
+
+ /**
+ * 扩展字段(用于原始/自定义字段透传)
+ */
+ public array $expand {
+ get => $this->expand;
+ }
+
+ /**
+ * @var array 需要上一个节点
+ */
+ public array $required = [];
+
+ public function __construct(
+ string $code,
+ string $class,
+ bool $enabled = true,
+ array $required = [],
+ array $expand = []
+ )
+ {
+ $this->code = $code;
+ $this->class = $class;
+ $this->enabled = $enabled;
+ $this->required = $required;
+ $this->expand = $expand;
+ }
+
+ public function toArray(): array
+ {
+ return array_merge([
+ 'code' => $this->code,
+ 'class' => $this->class,
+ 'enabled' => $this->enabled,
+ 'required' => $this->required,
+ ], $this->expand);
+ }
+}
diff --git a/app/flow/config/StepsConfig.php b/app/flow/config/StepsConfig.php
new file mode 100644
index 0000000..e8d885d
--- /dev/null
+++ b/app/flow/config/StepsConfig.php
@@ -0,0 +1,163 @@
+ $this->steps;
+ }
+
+ /**
+ * @param array $rawSteps 来自配置文件的原始步骤数组(每项为 ['code'=>..., 'class'=>..., 'enabled'=>...])
+ * @param bool $override true=完全替换默认步骤;false=追加到默认步骤之后
+ */
+ public function __construct(array $rawSteps = [], bool $override = true)
+ {
+ $parsed = array_map(fn(array $s) => self::createStep($s), $rawSteps);
+
+ // 无自定义步骤时始终使用默认列表,不受 override 影响
+ if (empty($parsed)) {
+ $this->steps = self::defaultSteps();
+ } elseif ($override) {
+ $this->steps = $parsed;
+ } else {
+ $this->steps = array_merge(self::defaultSteps(), $parsed);
+ }
+ }
+
+ /**
+ * 从原始配置数组创建
+ * 读取 $config['steps'] 与 $config['override_steps']
+ */
+ public static function create(array $config = [], bool $override = true): self
+ {
+ $instance = new self($config ?? [], $override);
+ $instance->log("加载[steps]步骤配置,来源: {}", [empty($config) ? "默认配置" : "自定义配置"]);
+ return $instance;
+ }
+
+ // ---- 默认步骤 ----
+
+ /**
+ * 标准手工洗流程默认步骤列表
+ *
+ * @return StepConfig[]
+ */
+ public static function defaultSteps(): array
+ {
+ return [
+ new StepConfig('晨洗', 'MorningWashNode'),
+ new StepConfig('重复刷卡', 'DuplicateCheckNode'),
+ new StepConfig('清洗', 'WashNode'),
+ new StepConfig('漂洗', 'RinseNode'),
+ new StepConfig('消毒', 'DisinfectNode'),
+ new StepConfig('终末漂洗', 'FinalRinseNode'),
+ new StepConfig('干燥', 'DryNode'),
+ new StepConfig('结束', 'EndNode'),
+ new StepConfig('机洗', 'MachineWashNode'),
+ new StepConfig('存储', 'StorageNode'),
+ new StepConfig('内镜放入', 'StorageInNode'),
+ new StepConfig('内镜取出', 'StorageOutNode'),
+ new StepConfig('Close', 'CloseNode'),
+ ];
+ }
+
+ // ---- 步骤查询 ----
+
+ /**
+ * 获取所有步骤
+ *
+ * @return StepConfig[]
+ */
+ public function all(): array
+ {
+ return $this->steps;
+ }
+
+ /**
+ * 获取启用的步骤
+ *
+ * @return StepConfig[]
+ */
+ public function enabled(): array
+ {
+ return array_values(array_filter($this->steps, fn(StepConfig $s) => $s->enabled));
+ }
+
+ /**
+ * 按 code 查找步骤
+ */
+ public function find(string $code): ?StepConfig
+ {
+ foreach ($this->steps as $step) {
+ if ($step->code === $code) {
+ return $step;
+ }
+ }
+ return null;
+ }
+
+ // ---- 步骤修改 ----
+
+ /**
+ * 追加一个步骤
+ */
+ public function add(string $code, string $class, bool $enabled = true, array $expand = []): self
+ {
+ $this->steps[] = new StepConfig($code, $class, $enabled, $expand);
+ return $this;
+ }
+
+ /**
+ * 移除步骤
+ */
+ public function remove(string $code): self
+ {
+ $steps = array_values(array_filter($this->steps, fn(StepConfig $s) => $s->code !== $code));
+ $this->steps = $steps;
+ return $this;
+ }
+
+ /**
+ * 设置节点启用状态
+ */
+ public function setEnabled(string $code, bool $enabled): self
+ {
+ foreach ($this->steps as $step) {
+ if ($step->code === $code) {
+ $step->enabled = $enabled;
+ }
+ }
+ return $this;
+ }
+
+ public function toArray(): array
+ {
+ return array_map(fn(StepConfig $s) => $s->toArray(), $this->steps);
+ }
+
+ // ---- 内部工厂 ----
+
+ /**
+ * 从数组创建单个步骤(兼容旧格式 ['code'=>..., 'class'=>..., 'enabled'=>...])
+ */
+ private static function createStep(array $step): StepConfig
+ {
+ $known = ['code', 'class', 'enabled'];
+ $expand = array_diff_key($step, array_flip($known));
+ return new StepConfig(
+ $step['code'],
+ $step['class'],
+ $step['enabled'] ?? true,
+ $expand
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/flow/config/TimeValidationConfig.php b/app/flow/config/TimeValidationConfig.php
new file mode 100644
index 0000000..0051e8f
--- /dev/null
+++ b/app/flow/config/TimeValidationConfig.php
@@ -0,0 +1,124 @@
+ [
+ '清洗' => 120,
+ '漂洗' => 60,
+ '消毒' => 300,
+ '终末漂洗' => 120,
+ '干燥' => 30,
+ '机洗' => 360,
+ ],
+ '机洗' => [
+ '清洗' => 120,
+ '漂洗' => 60,
+ '消毒' => 300,
+ '终末漂洗' => 120,
+ '干燥' => 30,
+ '机洗' => 360,
+ ],
+ '手工洗(加强)' => [
+ '清洗' => 60,
+ '漂洗' => 120,
+ '消毒' => 420,
+ '终末漂洗' => 120,
+ '干燥' => 30,
+ '机洗' => 360,
+ ],
+ '机洗(加强)' => [
+ '清洗' => 60,
+ '漂洗' => 120,
+ '消毒' => 420,
+ '终末漂洗' => 120,
+ '干燥' => 30,
+ '机洗' => 360,
+ ],
+ '手工洗(晨洗)' => [
+ '清洗' => 120,
+ '漂洗' => 60,
+ '消毒' => 300,
+ '终末漂洗' => 120,
+ '干燥' => 30,
+ '机洗' => 360,
+ ],
+ '机洗(晨洗)' => [
+ '清洗' => 120,
+ '漂洗' => 60,
+ '消毒' => 300,
+ '终末漂洗' => 120,
+ '干燥' => 30,
+ '机洗' => 360,
+ ],
+ ];
+
+ public function __construct(array $durations = [])
+ {
+ if (empty($durations)) {
+ $this->durations = ProcessDurationRepository::new()->getProcessDurations();
+ Logger::debug("加载[time_validation]时间验证配置,来源: 数据库");
+ } else {
+ $this->durations = array_merge($this->durations, $durations);
+ Logger::debug("加载[time_validation]时间验证配置,来源: 自定义参数");
+ }
+ }
+
+ /**
+ * 获取指定步骤的时长(不存在则返回 0)
+ */
+ public function getDuration(string $stepCode, string $processType = '手工洗'): int
+ {
+ $process = $this->durations[$processType];
+ if (!isset($process)) return 0;
+ return $process[$stepCode] ?? 0;
+ }
+
+ /**
+ * 设置指定步骤的时长
+ */
+ public function setDuration(string $stepCode, int $seconds, string $processType = '手工洗'): self
+ {
+ $this->durations[$processType][$stepCode] = $seconds;
+ return $this;
+ }
+
+ /**
+ * 判断步骤是否参与时间验证
+ */
+ public function hasStep(string $stepCode, string $processType = '手工洗'): bool
+ {
+ return array_key_exists($stepCode, $this->durations[$processType] ?? []);
+ }
+
+ /**
+ * 从数组创建(兼容旧配置格式 ['durations' => [...]])
+ */
+ public static function create(array $config = []): self
+ {
+ $durations = $config['durations'] ?? $config;
+ if (!is_array($durations)) {
+ $durations = [];
+ }
+ $instance = new self($durations);
+ return $instance;
+ }
+
+ public function toArray(): array
+ {
+ return ['durations' => $this->durations];
+ }
+}
diff --git a/app/flow/config/VoiceTemplatesConfig.php b/app/flow/config/VoiceTemplatesConfig.php
new file mode 100644
index 0000000..d18a252
--- /dev/null
+++ b/app/flow/config/VoiceTemplatesConfig.php
@@ -0,0 +1,150 @@
+ $this->config['morning_wash'];
+ }
+
+ /**
+ * 标准手工洗流程语音
+ */
+ public array $normalWash {
+ get => $this->config['normal_wash'];
+ }
+
+ /**
+ * 机洗流程语音
+ */
+ public array $machineWash {
+ get => $this->config['machine_wash'];
+ }
+
+ /**
+ * 测漏流程语音
+ */
+ public array $leakTest {
+ get => $this->config['leak_test'];
+ }
+
+ /**
+ * 存储流程语音
+ */
+ public array $storage {
+ get => $this->config['storage'];
+ }
+
+ /**
+ * 错误/提示语音(VoiceMessage name => 文本)
+ */
+ public array $voiceMessage {
+ get => $this->config['voice_message'];
+ }
+
+ protected array $config = [
+ 'morning_wash' => [
+ '消毒' => '手工晨洗 流程开始',
+ '机洗' => '机洗晨洗 开始',
+ ],
+ 'normal_wash' => [
+ 'start' => '清洗流程 开始',
+ '清洗' => '清洗',
+ '漂洗' => '漂洗',
+ '消毒' => '消毒',
+ '终末漂洗' => '终末漂洗',
+ '干燥' => '干燥',
+ '结束' => '流程结束',
+ ],
+ 'machine_wash' => [
+ 'start' => '机洗流程 开始',
+ '机洗' => '机洗完成',
+ ],
+ 'leak_test' => [
+ '测漏正常' => '测漏正常',
+ '测漏异常' => '内镜测漏异常,请检查',
+ ],
+ 'storage' => [
+ '内镜放入' => '内镜放入',
+ '内镜取出' => '内镜取出',
+ ],
+ 'voice_message' => [
+ 'wrong_step' => '刷错,请刷{expected}',
+ VoiceMessage::DUPLICATE_SWIPING->name => '刷错,重复刷卡',
+ 'not_completed' => '刷错,清洗完成才能入柜',
+ ],
+ ];
+
+ public function __construct(array $config = [])
+ {
+ $this->config = array_merge($this->config, $config);
+ }
+
+
+ /**
+ * 按模板 key 获取整组模板
+ */
+ public function getTemplates(string $key): array
+ {
+ return match ($key) {
+ 'morning_wash' => $this->morningWash,
+ 'normal_wash' => $this->normalWash,
+ 'machine_wash' => $this->machineWash,
+ 'leak_test' => $this->leakTest,
+ 'storage' => $this->storage,
+ 'voice_message' => $this->voiceMessage,
+ default => $this->expand[$key] ?? [],
+ };
+ }
+
+ /**
+ * 设置指定步骤的自定义语音
+ * @deprecated
+ */
+ public function setStepVoice(string $stepCode, string $voice): self
+ {
+ $this->config['custom'][$stepCode] = $voice;
+ return $this;
+ }
+
+ /**
+ * 从数组创建
+ *
+ * @param array $config = [
+ * 'morning_wash' => ['start' => '手工晨洗 流程开始', 'machine_start' => '机洗晨洗 开始'],
+ * 'normal_wash' => ['start' => '清洗流程 开始', '清洗' => '清洗', '漂洗' => '漂洗', ...],
+ * 'machine_wash' => ['start' => '机洗流程 开始', '机洗' => '机洗完成'],
+ * 'leak_test' => ['测漏正常' => '测漏正常', '测漏异常' => '内镜测漏异常,请检查'],
+ * 'storage' => ['内镜放入' => '内镜放入', '内镜取出' => '内镜取出'],
+ * 'voice_message' => ['wrong_step' => '刷错,请刷{expected}', 'not_completed' => '...'],
+ * ]
+ */
+ public static function create(array $config = []): self
+ {
+ $instance = new self($config);
+ $instance->log("加载[voice_templates]语音模板配置,来源: {}", [empty($config) ? "默认配置" : "自定义配置"]);
+ return $instance;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'morning_wash' => $this->morningWash,
+ 'normal_wash' => $this->normalWash,
+ 'machine_wash' => $this->machineWash,
+ 'leak_test' => $this->leakTest,
+ 'storage' => $this->storage,
+ 'voice_message' => $this->voiceMessage,
+ ];
+ }
+}
diff --git a/app/flow/docs/01-设计思路.md b/app/flow/docs/01-设计思路.md
new file mode 100644
index 0000000..e7481ed
--- /dev/null
+++ b/app/flow/docs/01-设计思路.md
@@ -0,0 +1,307 @@
+# 内镜清洗流程管理系统 - 设计思路
+
+## 一、项目背景
+
+医院内镜清洗流程复杂多变,不同医院有不同的业务需求:
+- 有的医院需要晨洗,有的不需要
+- 有的医院机洗后可以直接结束,有的必须继续手工流程
+- 有的医院对步骤时间有特殊要求
+- 流程步骤可能随时调整
+
+## 二、核心问题
+
+### 2.1 旧系统的问题
+1. **硬编码流程**:流程步骤写死在代码中,修改困难
+2. **状态黑盒**:使用数组存储状态,不知道在哪里被修改
+3. **无法扩展**:新增流程类型需要修改大量代码
+4. **分布式问题**:多台机器部署时,批次号无法保持一致
+
+### 2.2 需要支持的灵活场景
+1. 医院A:不需要晨洗
+2. 医院B:只有部分镜子需要晨洗
+3. 医院C:机洗后不允许刷终末漂洗,只能刷干燥或结束
+4. 医院D:只需要清洗和结束两个步骤
+5. 医院E:自定义每个步骤的语音播报
+
+## 三、设计方案
+
+### 3.1 架构模式
+
+#### 责任链模式 (Chain of Responsibility)
+```
+Request → Node1 → Node2 → Node3 → ... → Response
+ ↓ ↓ ↓
+ 处理或传递 处理或传递 处理或传递
+```
+
+**优点**:
+- 每个节点只关心自己能否处理
+- 节点可以动态添加/删除/禁用
+- 流程顺序可配置
+
+#### 策略模式 (Strategy)
+```
+Context → StrategyA
+ → StrategyB
+ → StrategyC
+```
+
+**优点**:
+- 晨洗判断逻辑可替换
+- 时间验证规则可配置
+- 语音生成模板可定制
+
+### 3.2 核心组件
+
+#### ProcessContext(流程上下文)
+```php
+class ProcessContext
+{
+ // 明确字段,避免黑盒
+ public string $endoscopeId = '';
+ public string $currentStep = '';
+ public string $batchNo = '';
+ public array $stepLastTimes = [];
+ // ...
+}
+```
+
+**设计原则**:
+- 所有状态都是明确的属性
+- 不使用 `getExtra/setExtra` 黑盒方法
+- 支持从 PacketContext 初始化
+
+#### ProcessNode(流程节点)
+```php
+interface ProcessNodeInterface
+{
+ public function canHandle(ProcessContext $context): bool;
+ public function handle(ProcessContext $context): ProcessContext;
+ public function setNext(ProcessNodeInterface $next): ProcessNodeInterface;
+}
+```
+
+**节点列表**:
+- MorningWashNode:晨洗节点
+- WashNode:清洗节点
+- RinseNode:漂洗节点
+- DisinfectNode:消毒节点
+- FinalRinseNode:终末漂洗节点
+- DryNode:干燥节点
+- EndNode:结束节点
+- MachineWashNode:机洗节点
+
+#### ProcessStrategy(流程策略)
+```php
+interface ProcessStrategyInterface
+{
+ public function execute(ProcessContext $context, ProcessNodeInterface $node): ProcessContext;
+}
+```
+
+**策略列表**:
+- MorningWashStrategy:晨洗判断(5种模式)
+- TimeValidationStrategy:时间验证
+- VoiceGenerationStrategy:语音生成
+
+### 3.3 分布式批次号一致性
+
+**问题**:机器A处理清洗,机器B处理消毒,如何保证批次号相同?
+
+**解决方案**:
+```php
+public function getOrCreateBatchNo(bool $forceNew = false): string
+{
+ // 1. 首先查询数据库获取该内镜未完成的批次号
+ $existingBatchNo = Database::query(
+ "SELECT op_batchno FROM ect_actions
+ WHERE endoscope_id = ?
+ AND process_name != '结束'
+ AND created_at >= CURDATE()
+ ORDER BY action_id DESC LIMIT 1"
+ );
+
+ // 2. 如果存在,使用已有的;如果不存在,生成新的
+ if ($existingBatchNo) {
+ return $existingBatchNo;
+ }
+
+ return $this->generateBatchNo();
+}
+```
+
+**批次号格式**:`YYYYMMDD + HHMMSS + 内镜ID(6位) + 随机数(4位)`
+- 示例:`202603031230450000011234`
+- 长度固定:24位
+- 包含时间戳,便于排序
+
+## 四、流程执行流程
+
+### 4.1 整体流程图
+
+```
+┌─────────────────┐
+│ 接收刷卡数据 │
+│ PacketContext │
+└────────┬────────┘
+ │
+ ▼
+┌─────────────────┐
+│ 查询数据库 │
+│ - 内镜信息 │
+│ - 历史记录 │
+│ - 当前批次号 │
+└────────┬────────┘
+ │
+ ▼
+┌─────────────────┐
+│ 创建ProcessContext│
+└────────┬────────┘
+ │
+ ▼
+┌─────────────────┐
+│ 执行责任链 │
+│ - 晨洗判断 │
+│ - 时间验证 │
+│ - 节点处理 │
+│ - 语音生成 │
+└────────┬────────┘
+ │
+ ▼
+┌─────────────────┐
+│ 处理结果 │
+│ - 插入数据库 │
+│ - 语音播报 │
+│ - WebSocket通知 │
+└─────────────────┘
+```
+
+### 4.2 策略执行详解
+
+责任链中的每个节点都可以配置多个策略,策略在节点处理前后执行:
+
+```
+┌─────────────────────────────────────────┐
+│ 节点处理流程 │
+├─────────────────────────────────────────┤
+│ │
+│ ┌─────────────┐ ┌─────────────┐ │
+│ │ 前置策略执行 │ → │ MorningWash │ │
+│ │ │ │ Strategy │ │
+│ └─────────────┘ └─────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌─────────────┐ ┌─────────────┐ │
+│ │ 前置策略执行 │ → │ TimeValidat │ │
+│ │ │ │ ionStrategy │ │
+│ └─────────────┘ └─────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌─────────────────────────────────┐ │
+│ │ 节点核心逻辑处理 │ │
+│ │ (canHandle + handle) │ │
+│ └─────────────────────────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌─────────────┐ ┌─────────────┐ │
+│ │ 后置策略执行 │ → │ VoiceGenerat│ │
+│ │ │ │ ionStrategy │ │
+│ └─────────────┘ └─────────────┘ │
+│ │
+└─────────────────────────────────────────┘
+```
+
+**策略执行顺序**:
+
+1. **前置策略**(按配置顺序执行)
+ - `MorningWashStrategy`:判断是否需要晨洗
+ - `TimeValidationStrategy`:验证步骤时间间隔
+
+2. **节点核心逻辑**
+ - 调用 `canHandle()` 判断当前节点是否能处理
+ - 调用 `handle()` 执行业务逻辑
+
+3. **后置策略**
+ - `VoiceGenerationStrategy`:生成语音播报内容
+
+**代码示例**:
+
+```php
+// 在 AbstractProcessNode 中执行策略
+final public function process(ProcessContext $context): ProcessContext
+{
+ // 1. 执行前置策略
+ foreach ($this->preStrategies as $strategy) {
+ $context = $strategy->execute($context, $this);
+ }
+
+ // 2. 检查是否能处理
+ if (!$this->canHandle($context)) {
+ // 传递给下一个节点
+ if ($this->next !== null) {
+ return $this->next->process($context);
+ }
+ return $context;
+ }
+
+ // 3. 执行节点核心逻辑
+ $context = $this->handle($context);
+
+ // 4. 执行后置策略
+ foreach ($this->postStrategies as $strategy) {
+ $context = $strategy->execute($context, $this);
+ }
+
+ return $context;
+}
+```
+
+**策略配置示例**:
+
+```php
+// 在 ProcessConfig 中配置策略
+'nodes' => [
+ 'wash' => [
+ 'class' => WashNode::class,
+ 'pre_strategies' => [
+ MorningWashStrategy::class, // 晨洗判断
+ TimeValidationStrategy::class, // 时间验证
+ ],
+ 'post_strategies' => [
+ VoiceGenerationStrategy::class, // 语音生成
+ ],
+ ],
+],
+```
+
+## 五、配置化设计
+
+### 5.1 流程配置
+```php
+$config = [
+ 'steps' => [
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true],
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => true],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ],
+ 'morning_wash' => [
+ 'mode' => 'none', // 不需要晨洗
+ ],
+];
+```
+
+### 5.2 晨洗模式
+- `none`:不需要晨洗
+- `all`:所有镜子都需要
+- `storage_time`:根据存储时间判断(义乌模式)
+- `daily_first`:每天第一次(忠县模式)
+- `specific_types`:特定类型镜子需要
+
+### 5.3 医院特殊配置
+```php
+// FinalRinseNode:机洗后不允许刷终末漂洗
+$node->setAllowAfterMachineWash(false);
+
+// DryNode:机洗后必须直接刷干燥
+$node->setRequireDirectAfterMachineWash(true);
+```
diff --git a/app/flow/docs/02-使用配置.md b/app/flow/docs/02-使用配置.md
new file mode 100644
index 0000000..f82cc01
--- /dev/null
+++ b/app/flow/docs/02-使用配置.md
@@ -0,0 +1,470 @@
+# 内镜清洗流程管理系统 - 使用与配置
+
+## 一、快速开始
+
+### 1.1 基础使用示例
+
+```php
+use app\flow\FlowProcessor;
+use app\flow\config\ProcessConfig;
+use app\net\PacketContext;
+
+// 接收刷卡数据
+public function onMessage(TcpConnection $connection, $data): void
+{
+ // 1. 解析数据包
+ $packet = PacketParserFactory::parse($data);
+ $packetContext = new PacketContext($connection, $packet);
+
+ // 2. 创建流程处理器(使用标准配置)
+ $config = ProcessConfig::createStandard();
+ $processor = new FlowProcessor($config);
+
+ // 3. 处理流程
+ $result = $processor->process($packetContext);
+
+ // 4. 输出结果
+ if ($result->success) {
+ echo "语音播报: " . $result->getFullVoice();
+ echo "当前步骤: " . $result->currentStep;
+ echo "批次号: " . $result->batchNo;
+ } else {
+ echo "错误: " . $result->errorMessage;
+ }
+}
+```
+
+## 二、配置方式
+
+### 2.1 预置配置
+
+```php
+use app\flow\config\ProcessConfig;
+
+// 标准完整流程
+$config = ProcessConfig::createStandard();
+
+// 无晨洗流程
+$config = ProcessConfig::createNoMorningWash();
+
+// 简化流程(只清洗)
+$config = ProcessConfig::createSimple();
+
+// 机洗流程
+$config = ProcessConfig::createMachineWash();
+
+// 无干燥流程
+$config = ProcessConfig::createNoDry();
+
+// 仅干燥流程
+$config = ProcessConfig::createDryOnly();
+```
+
+### 2.2 自定义配置
+
+```php
+use app\flow\config\ProcessConfig;
+
+$config = new ProcessConfig([
+ // 定义的 steps 是带有顺序的
+ // 流程步骤配置
+ 'steps' => [
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true],
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => true],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ],
+
+ // 晨洗配置
+ 'morning_wash' => [
+ 'mode' => 'storage_time', // 根据存储时间判断
+ 'storage_threshold' => 4, // 4小时阈值
+ 'morning_start_time' => '06:00:00',
+ ],
+
+ // 时间验证配置
+ 'time_validation' => [
+ 'durations' => [
+ '清洗' => 300, // 5分钟
+ '消毒' => 600, // 10分钟(自定义)
+ ],
+ ],
+
+ // 语音模板配置
+ 'voice_templates' => [
+ 'custom' => [
+ '清洗' => '第一步清洗开始',
+ '消毒' => '第三步消毒开始,请确保消毒时间',
+ ],
+ ],
+]);
+```
+
+### 2.3 动态调整配置
+
+```php
+use app\flow\ProcessEngine;
+
+$engine = ProcessEngine::createStandard();
+
+// 禁用某些步骤
+$engine->disableNode('干燥');
+$engine->disableNode('终末漂洗');
+
+// 启用步骤
+$engine->enableNode('干燥');
+
+// 设置自定义语音
+$engine->setStepVoice('清洗', '请开始清洗');
+
+// 设置晨洗模式
+$engine->setMorningWashMode('none');
+```
+
+## 三、晨洗模式配置
+
+### 3.1 不需要晨洗
+```php
+$config = new ProcessConfig([
+ 'morning_wash' => [
+ 'mode' => 'none',
+ ],
+]);
+```
+
+### 3.2 所有镜子都需要晨洗
+```php
+$config = new ProcessConfig([
+ 'morning_wash' => [
+ 'mode' => 'all',
+ ],
+]);
+```
+
+### 3.3 根据存储时间判断(义乌模式)
+```php
+$config = new ProcessConfig([
+ 'morning_wash' => [
+ 'mode' => 'storage_time',
+ 'storage_threshold' => 4, // 超过4小时需要晨洗
+ 'morning_start_time' => '06:00:00',
+ ],
+]);
+```
+
+### 3.4 每天第一次需要晨洗(忠县模式)
+```php
+$config = new ProcessConfig([
+ 'morning_wash' => [
+ 'mode' => 'daily_first',
+ 'morning_start_time' => '06:00:00',
+ ],
+]);
+```
+
+### 3.5 特定类型镜子需要晨洗
+```php
+$config = new ProcessConfig([
+ 'morning_wash' => [
+ 'mode' => 'specific_types',
+ 'specific_types' => ['胃镜', '十二指肠镜'],
+ ],
+]);
+```
+
+## 四、医院特殊流程配置
+
+### 4.1 机洗后不允许刷终末漂洗
+
+```php
+use app\flow\ProcessEngine;
+
+$engine = ProcessEngine::createStandard();
+
+// 获取终末漂洗节点并配置
+$finalRinseNode = $engine->getNode('终末漂洗');
+$finalRinseNode->setAllowAfterMachineWash(false);
+```
+
+**效果**:机洗完成后,刷卡到终末漂洗读卡器会提示错误,只能刷干燥或结束。
+
+### 4.2 机洗后必须直接刷干燥
+
+```php
+$dryNode = $engine->getNode('干燥');
+$dryNode->setRequireDirectAfterMachineWash(true);
+```
+
+**效果**:机洗完成后,必须刷干燥或结束,不能刷其他步骤。
+
+### 4.3 自定义步骤时间
+
+```php
+use app\flow\strategies\TimeValidationStrategy;
+
+$strategy = new TimeValidationStrategy([
+ 'durations' => [
+ '清洗' => 600, // 10分钟
+ '消毒' => 900, // 15分钟
+ ],
+]);
+
+$engine->addStrategy('time_validation', $strategy);
+```
+
+### 4.4 自定义语音模板
+
+```php
+$engine->setStepVoice('清洗', '第一步:请开始清洗内镜');
+$engine->setStepVoice('消毒', '第三步:消毒时间必须达到5分钟');
+$engine->setStepVoice('结束', '清洗流程完成,请妥善保管');
+```
+
+## 五、从配置文件加载
+
+### 5.1 创建配置文件
+
+```php
+// config/flow/hospital_a.php
+return [
+ 'name' => '医院A-无晨洗流程',
+ 'morning_wash' => [
+ 'mode' => 'none',
+ ],
+ 'steps' => [
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true],
+ ['code' => '漂洗', 'class' => 'RinseNode', 'enabled' => true],
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => true],
+ ['code' => '终末漂洗', 'class' => 'FinalRinseNode', 'enabled' => true],
+ ['code' => '干燥', 'class' => 'DryNode', 'enabled' => true],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ],
+];
+```
+
+### 5.2 加载配置
+
+```php
+$config = ProcessConfig::fromFile(__DIR__ . '/config/flow/hospital_a.php');
+$processor = new FlowProcessor($config);
+```
+
+## 六、全局配置类(Config.php)
+
+### 6.1 配置类说明
+
+系统提供全局配置单例类 `app\config\Config`,用于统一管理数据库配置和自定义流程配置:
+
+```php
+use app\config\Config;
+
+// 获取配置实例
+$config = Config::getInstance();
+
+// 获取数据库配置
+$databaseConfig = $config->database;
+
+// 获取自定义流程配置(从 app/config/custom_process_config.php 加载)
+$customProcess = $config->customProcess;
+```
+
+### 6.2 配置文件位置
+
+自定义流程配置文件位于:`app/config/custom_process_config.php`
+
+该文件返回一个数组,包含多种预设的医院流程配置:
+
+```php
+return [
+ 'standard' => [...], // 标准完整流程
+ 'no_morning_wash' => [...], // 无晨洗流程
+ 'partial_morning_wash' => [...], // 部分镜子晨洗(义乌模式)
+ 'no_dry' => [...], // 无干燥流程
+ 'dry_only' => [...], // 仅干燥流程
+ 'machine_wash' => [...], // 机洗流程
+ 'simple' => [...], // 简化流程
+ 'custom_voice' => [...], // 自定义语音流程
+];
+```
+
+### 6.3 使用全局配置创建流程
+
+```php
+use app\config\Config;
+use app\flow\ProcessConfig;
+use app\flow\FlowProcessor;
+
+// 获取全局配置
+$globalConfig = Config::getInstance();
+
+// 从全局配置中获取特定医院配置
+$hospitalConfig = $globalConfig->customProcess['no_morning_wash'] ?? null;
+
+if ($hospitalConfig) {
+ // 创建流程配置
+ $processConfig = ProcessConfig::fromArray($hospitalConfig);
+
+ // 创建流程处理器
+ $processor = new FlowProcessor($processConfig);
+
+ // 处理刷卡请求...
+}
+```
+
+### 6.4 配置优先级
+
+配置加载优先级(从高到低):
+1. 代码中动态设置的配置(`$engine->setStepVoice()`)
+2. 从配置文件加载的配置(`ProcessConfig::fromFile()`)
+3. 全局配置类中的配置(`Config::getInstance()->customProcess`)
+4. 默认配置(`ProcessConfig::createStandard()`)
+
+## 七、多医院配置示例
+
+```php
+class HospitalFlowManager
+{
+ protected array $processors = [];
+
+ public function __construct()
+ {
+ // 医院A:标准流程
+ $this->processors['hospital_a'] = new FlowProcessor(
+ ProcessConfig::createStandard()
+ );
+
+ // 医院B:无晨洗
+ $this->processors['hospital_b'] = new FlowProcessor(
+ ProcessConfig::createNoMorningWash()
+ );
+
+ // 医院C:义乌模式
+ $configC = new ProcessConfig([
+ 'morning_wash' => [
+ 'mode' => 'storage_time',
+ 'storage_threshold' => 4,
+ ],
+ ]);
+ $this->processors['hospital_c'] = new FlowProcessor($configC);
+
+ // 医院D:机洗流程
+ $this->processors['hospital_d'] = new FlowProcessor(
+ ProcessConfig::createMachineWash()
+ );
+ }
+
+ public function process(string $hospitalId, PacketContext $packetContext): ProcessContext
+ {
+ $processor = $this->processors[$hospitalId] ?? $this->processors['hospital_a'];
+ return $processor->process($packetContext);
+ }
+}
+```
+
+## 八、处理结果
+
+### 8.1 成功结果
+
+```php
+$result = $processor->process($packetContext);
+
+if ($result->isSuccess()) {
+ // 基础信息
+ $result->endoscopeId; // 内镜ID
+ $result->endoscopeName; // 内镜名称
+ $result->currentStep; // 当前步骤
+ $result->processType; // 流程类型
+ $result->batchNo; // 批次号
+
+ // 语音播报
+ $voice = $result->getFullVoice(); // 完整语音(含内镜名称)
+ $voice = $result->voiceMessage; // 仅流程语音
+
+ // 数据库标记
+ $result->needDbInsert; // 是否需要插入数据库
+ $result->dbOperation; // insert / update
+
+ // WebSocket标记
+ $result->needWebSocketNotify; // 是否需要发送通知
+}
+```
+
+### 8.2 失败结果
+
+```php
+if (!$result->isSuccess()) {
+ $errorMessage = $result->errorMessage; // 错误信息
+ $voice = $result->voiceMessage; // 错误语音
+
+ // 常见错误:
+ // - "刷错,清洗剩余180秒"(时间未到)
+ // - "刷卡错误,请刷消毒"(步骤错误)
+ // - "内镜未绑定"(数据错误)
+}
+```
+
+## 九、数据库表结构参考
+
+### 9.1 流程配置表(ect_meta_process)
+
+```sql
+CREATE TABLE `ect_meta_process` (
+ `process_id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
+ `process_name` varchar(255) COMMENT '流程名称(清洗/消毒/干燥等)',
+ `process_duration` int COMMENT '流程时长(秒)',
+ `process_type` varchar(20) COMMENT '洗消类型(手工洗/机洗等)',
+ `optional` int COMMENT '0=选配 1=必配',
+ `process_order` int COMMENT '顺序',
+ `status` int COMMENT '1=启用 0=禁用',
+ PRIMARY KEY (`process_id`)
+);
+```
+
+### 9.2 操作记录表(ect_actions)
+
+```sql
+CREATE TABLE `ect_actions` (
+ `action_id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
+ `batch_no` varchar(50) COMMENT '批次号(同一次清洗流程相同)',
+ `action_type` int COMMENT '操作类型',
+ `action_type_name` varchar(50) COMMENT '操作类型名称',
+ `process_name` varchar(50) COMMENT '流程名称(清洗/消毒等)',
+ `reader_id` bigint COMMENT '读卡器ID',
+ `reader_no` varchar(50) COMMENT '读卡器编号',
+ `endoscope_id` bigint COMMENT '内镜ID',
+ `endoscope_rfid` varchar(50) COMMENT '内镜RFID',
+ `endoscope_name` varchar(100) COMMENT '内镜名称',
+ `op_starttime` datetime COMMENT '操作开始时间',
+ `op_endtime` datetime COMMENT '操作结束时间',
+ `created_at` datetime,
+ PRIMARY KEY (`action_id`)
+);
+```
+
+## 十、常见问题
+
+### Q1: 如何跳过某个步骤?
+```php
+$engine->disableNode('干燥');
+```
+
+### Q2: 如何修改步骤时间?
+```php
+$config = new ProcessConfig([
+ 'time_validation' => [
+ 'durations' => ['消毒' => 600],
+ ],
+]);
+```
+
+### Q3: 批次号如何保证分布式一致?
+系统会从数据库查询该内镜未完成的流程批次号,如果存在则使用已有批次号,不存在则生成新的。
+
+### Q4: 如何添加自定义语音?
+```php
+$engine->setStepVoice('清洗', '自定义语音内容');
+```
+
+### Q5: 如何支持新的流程类型?
+1. 创建新的节点类继承 `AbstractProcessNode`
+2. 在配置中添加步骤
+3. 实现 `canHandle()` 和 `doHandle()` 方法
diff --git a/app/flow/docs/03-DIY扩展.md b/app/flow/docs/03-DIY扩展.md
new file mode 100644
index 0000000..62f2e9a
--- /dev/null
+++ b/app/flow/docs/03-DIY扩展.md
@@ -0,0 +1,632 @@
+# 内镜清洗流程管理系统 - DIY扩展指南
+
+## 一、扩展概述
+
+本系统采用责任链模式 + 策略模式设计,提供了良好的扩展性。你可以:
+- 添加新的流程节点
+- 添加新的策略
+- 自定义流程配置
+- 修改现有节点行为
+
+## 二、添加新的流程节点
+
+### 2.1 场景示例
+假设医院需要一个"预处理"步骤,在清洗之前进行。
+
+### 2.2 创建节点类
+
+```php
+readerType !== '预处理') {
+ return false;
+ }
+
+ // 上一个步骤必须是空或结束
+ $validSteps = ['', '结束', '内镜取出'];
+ return in_array($context->currentStep, $validSteps);
+ }
+
+ /**
+ * 具体处理逻辑
+ */
+ protected function doHandle(ProcessContext $context): ProcessContext
+ {
+ // 更新步骤
+// $context->lastStep = $context->currentStep;
+ $context->currentStep = '预处理';
+
+ // 记录操作时间
+ $context->setStepLastTime('预处理', date('Y-m-d H:i:s'));
+
+ // TODO: 插入数据库记录
+ $context->needDatabaseOperation = true;
+ $context->dbOperation = 'insert';
+
+ // TODO: 发送WebSocket通知
+ $context->needWebSocketNotify = true;
+
+ return $context;
+ }
+}
+```
+
+### 2.3 注册节点
+
+```php
+use app\flow\ProcessEngine;
+use app\flow\nodes\PreprocessNode;
+
+$engine = ProcessEngine::createStandard();
+
+// 创建节点实例
+$preprocessNode = new PreprocessNode();
+
+// 添加策略
+$preprocessNode->addStrategy($engine->getStrategy('voice_generation'));
+
+// 插入到责任链中(在清洗节点之前)
+$washNode = $engine->getNode('清洗');
+$preprocessNode->setNext($washNode);
+
+// 更新链头
+// 注意:这里需要修改 ProcessEngine 的内部实现
+```
+
+### 2.4 通过配置添加
+
+```php
+use app\flow\config\ProcessConfig;
+
+$config = new ProcessConfig([
+ 'steps' => [
+ ['code' => '晨洗', 'class' => 'MorningWashNode', 'enabled' => true],
+ ['code' => '预处理', 'class' => 'PreprocessNode', 'enabled' => true], // 新增
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true],
+ ['code' => '漂洗', 'class' => 'RinseNode', 'enabled' => true],
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => true],
+ ['code' => '终末漂洗', 'class' => 'FinalRinseNode', 'enabled' => true],
+ ['code' => '干燥', 'class' => 'DryNode', 'enabled' => true],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ],
+]);
+
+$engine = new ProcessEngine($config);
+```
+
+## 三、添加新的策略
+
+### 3.1 场景示例
+假设需要添加一个"操作员验证策略",验证是否已刷人员卡。
+
+### 3.2 创建策略类
+
+```php
+operatorId)) {
+ $context->setError('请先刷人员卡');
+ return $context;
+ }
+
+ // 检查人员卡是否有效
+ if (!$this->isValidOperator($context->operatorId)) {
+ $context->setError('人员卡无效');
+ return $context;
+ }
+
+ return $context;
+ }
+
+ /**
+ * 验证操作员是否有效
+ * TODO: 从数据库查询
+ */
+ protected function isValidOperator(string $operatorId): bool
+ {
+ // TODO: 查询数据库验证操作员
+ // SQL: SELECT COUNT(*) FROM ect_user WHERE user_id = ? AND status = 1
+ return true;
+ }
+
+ /**
+ * 判断策略是否适用
+ * 只有清洗步骤需要验证操作员
+ */
+ public function isApplicable(ProcessContext $context, ProcessNodeInterface $node): bool
+ {
+ return $node->getCode() === '清洗';
+ }
+
+ /**
+ * 获取策略名称
+ */
+ public function getName(): string
+ {
+ return '操作员验证策略';
+ }
+}
+```
+
+### 3.3 注册策略
+
+```php
+use app\flow\ProcessEngine;
+use app\flow\strategies\OperatorValidationStrategy;
+
+$engine = ProcessEngine::createStandard();
+
+// 创建策略实例
+$operatorStrategy = new OperatorValidationStrategy();
+
+// 添加到引擎
+$engine->addStrategy('operator_validation', $operatorStrategy);
+
+// 将策略添加到指定节点
+$washNode = $engine->getNode('清洗');
+$washNode->addStrategy($operatorStrategy);
+```
+
+## 四、修改现有节点行为
+
+### 4.1 继承并覆盖
+
+```php
+logCustomInfo($context);
+
+ return $context;
+ }
+
+ /**
+ * 自定义验证逻辑
+ */
+ public function canHandle(ProcessContext $context): bool
+ {
+ // 先调用父类验证
+ if (!parent::canHandle($context)) {
+ return false;
+ }
+
+ // 添加额外的验证条件
+ // 例如:某些类型的内镜不能在此节点处理
+ if ($context->endoscopeType === '特殊镜') {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * 自定义日志
+ */
+ protected function logCustomInfo(ProcessContext $context): void
+ {
+ // 自定义日志逻辑
+ error_log("[CustomWash] 内镜: {$context->endoscopeName}, 时间: " . date('Y-m-d H:i:s'));
+ }
+}
+```
+
+### 4.2 使用配置替换节点
+
+```php
+$config = new ProcessConfig([
+ 'steps' => [
+ ['code' => '清洗', 'class' => 'app\flow\nodes\custom\CustomWashNode', 'enabled' => true],
+ // ... 其他步骤
+ ],
+]);
+```
+
+## 五、自定义流程配置加载器
+
+### 5.1 从数据库加载配置
+
+```php
+ [
+ 'mode' => 'daily_first',
+ ],
+ 'steps' => self::loadStepsFromDb($hospitalId),
+ 'time_validation' => [
+ 'durations' => self::loadDurationsFromDb($hospitalId),
+ ],
+ ];
+
+ return new ProcessConfig($configData);
+ }
+
+ /**
+ * 从数据库加载步骤配置
+ */
+ protected static function loadStepsFromDb(string $hospitalId): array
+ {
+ // TODO: 查询数据库
+ // SQL: SELECT * FROM ect_hospital_steps WHERE hospital_id = ? ORDER BY step_order
+
+ return [
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true],
+ ['code' => '消毒', 'class' => 'DisinfectNode', 'enabled' => true],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ];
+ }
+
+ /**
+ * 从数据库加载时长配置
+ */
+ protected static function loadDurationsFromDb(string $hospitalId): array
+ {
+ // TODO: 查询数据库
+ // SQL: SELECT * FROM ect_hospital_durations WHERE hospital_id = ?
+
+ return [
+ '清洗' => 300,
+ '消毒' => 300,
+ ];
+ }
+}
+```
+
+### 5.2 使用加载器
+
+```php
+use app\flow\config\DatabaseConfigLoader;
+
+// 从数据库加载配置
+$config = DatabaseConfigLoader::load('hospital_001');
+$processor = new FlowProcessor($config);
+```
+
+## 六、自定义语音生成器
+
+### 6.1 创建语音生成器
+
+```php
+processType) {
+ case '手工洗':
+ $voice = $this->generateManualWashVoice($context);
+ break;
+ case '机洗':
+ $voice = $this->generateMachineWashVoice($context);
+ break;
+ default:
+ $voice = $this->generateDefaultVoice($context);
+ }
+
+ // 添加内镜名称
+ $name = str_replace(['北院', '南院', '电子'], '', $context->endoscopeName);
+ return $name . $voice;
+ }
+
+ /**
+ * 手工洗语音
+ */
+ protected function generateManualWashVoice(ProcessContext $context): string
+ {
+ $templates = [
+ '清洗' => '手工清洗开始,请认真清洗',
+ '漂洗' => '漂洗开始',
+ '消毒' => '消毒开始,请确保消毒时间',
+ '终末漂洗' => '终末漂洗开始',
+ '干燥' => '干燥开始',
+ '结束' => '手工清洗流程结束',
+ ];
+
+ return $templates[$context->currentStep] ?? $context->currentStep . '完成';
+ }
+
+ /**
+ * 机洗语音
+ */
+ protected function generateMachineWashVoice(ProcessContext $context): string
+ {
+ $templates = [
+ '机洗' => '机器清洗开始',
+ '结束' => '机洗流程结束',
+ ];
+
+ return $templates[$context->currentStep] ?? $context->currentStep . '完成';
+ }
+
+ /**
+ * 默认语音
+ */
+ protected function generateDefaultVoice(ProcessContext $context): string
+ {
+ return $context->currentStep . '完成';
+ }
+}
+```
+
+### 6.2 集成到策略
+
+```php
+// 修改 VoiceGenerationStrategy
+protected function generateNormalVoice(ProcessContext $context, ProcessNodeInterface $node): string
+{
+ // 使用自定义语音生成器
+ $generator = new CustomVoiceGenerator();
+ return $generator->generate($context);
+}
+```
+
+## 七、自定义数据库操作
+
+### 7.1 创建数据库处理器
+
+```php
+dbOperation === 'insert') {
+ $this->insertAction($context);
+ } elseif ($context->dbOperation === 'update') {
+ $this->updateAction($context);
+ }
+ }
+
+ /**
+ * 插入新记录
+ */
+ protected function insertAction(ProcessContext $context): void
+ {
+ // TODO: 实现插入逻辑
+ $sql = "INSERT INTO ect_actions (
+ batch_no, action_type, action_type_name, process_name,
+ reader_id, reader_no,
+ opuser_type, opuser_id, opuser_rfid, opuser_name,
+ endoscope_id, endoscope_rfid, endoscope_name,
+ created_at, op_starttime
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+
+ $params = [
+ $context->batchNo,
+ $this->getActionType($context->processType),
+ $context->processType,
+ $context->currentStep,
+ $context->readerId,
+ $context->readerNo,
+ 2, // 清洗人员
+ $context->operatorId,
+ $context->operatorRfid,
+ $context->operatorName,
+ $context->endoscopeId,
+ $context->cardNo,
+ $context->endoscopeName,
+ date('Y-m-d H:i:s'),
+ date('Y-m-d H:i:s'),
+ ];
+
+ // Database::execute($sql, $params);
+ }
+
+ /**
+ * 更新记录
+ */
+ protected function updateAction(ProcessContext $context): void
+ {
+ // TODO: 实现更新逻辑
+ $sql = "UPDATE ect_actions
+ SET op_endtime = ?
+ WHERE batch_no = ?
+ ORDER BY action_id DESC
+ LIMIT 1";
+
+ // Database::execute($sql, [date('Y-m-d H:i:s'), $context->batchNo]);
+ }
+
+ /**
+ * 获取操作类型编码
+ */
+ protected function getActionType(string $processType): int
+ {
+ $mapping = [
+ '诊疗' => 0,
+ '手工洗' => 1,
+ '机洗' => 2,
+ '测漏' => 7,
+ '存储' => 8,
+ ];
+
+ return $mapping[$processType] ?? 1;
+ }
+}
+```
+
+### 7.2 集成到 FlowProcessor
+
+```php
+// 修改 FlowProcessor::saveToDatabase()
+protected function saveToDatabase(ProcessContext $context): void
+{
+ $handler = new CustomDbHandler();
+ $handler->save($context);
+}
+```
+
+## 八、扩展最佳实践
+
+### 8.1 命名规范
+- 节点类:`XxxNode`,位于 `app/flow/nodes/`
+- 策略类:`XxxStrategy`,位于 `app/flow/strategies/`
+- 配置类:`XxxConfig`,位于 `app/flow/config/`
+
+### 8.2 测试建议
+```php
+// 单元测试示例
+class PreprocessNodeTest
+{
+ public function testCanHandle()
+ {
+ $node = new PreprocessNode();
+
+ $context = ProcessContext::create([
+ 'readerType' => '预处理',
+ 'currentStep' => '',
+ ]);
+
+ assert($node->canHandle($context) === true);
+ }
+}
+```
+
+### 8.3 调试技巧
+```php
+// 打印流程上下文
+var_dump($context);
+
+// 打印责任链结构
+$node = $engine->getChainHead();
+while ($node !== null) {
+ echo $node->getName() . ' -> ';
+ $node = $node->getNext();
+}
+```
+
+## 九、扩展示例汇总
+
+| 扩展类型 | 难度 | 参考章节 |
+|---------|------|---------|
+| 添加新节点 | 低 | 2.1-2.4 |
+| 添加新策略 | 低 | 3.1-3.3 |
+| 修改节点行为 | 中 | 4.1-4.2 |
+| 自定义配置加载 | 中 | 5.1-5.2 |
+| 自定义语音 | 低 | 6.1-6.2 |
+| 自定义数据库 | 中 | 7.1-7.2 |
+
+## 十、常见问题
+
+### Q1: 如何调试新添加的节点?
+在 `doHandle()` 方法中添加日志输出,查看上下文数据。
+
+### Q2: 策略执行顺序如何控制?
+策略按照添加顺序执行,可以使用 `setPhase('before'/'after')` 控制执行阶段。
+
+### Q3: 如何禁用默认策略?
+可以覆盖 `attachStrategies()` 方法或创建自定义引擎。
+
+### Q4: 如何处理数据库事务?
+在 `FlowProcessor::handleResult()` 中添加事务控制逻辑。
diff --git a/app/flow/exception/IllegalUsageException.php b/app/flow/exception/IllegalUsageException.php
new file mode 100644
index 0000000..a71b0c8
--- /dev/null
+++ b/app/flow/exception/IllegalUsageException.php
@@ -0,0 +1,19 @@
+next = $next;
+ return $this;
+ }
+
+ /**
+ * 获取下一个节点
+ */
+ public function getNext(): ?ProcessNodeInterface
+ {
+ return $this->next;
+ }
+
+ /**
+ * 从当前节点开始处理流程链
+ */
+ public function handle(ProcessContext $context): ProcessContext
+ {
+ // 如果节点被禁用,直接传递给下一个节点
+ if (!$this->isEnabled()) {
+ Logger::debug('[{}-Node] 节点已禁用,跳过', [$this->getCode()]);
+ return $this->passToNext($context);
+ }
+
+ // 执行前置策略
+ $context = $this->executeBeforeStrategies($context);
+
+ // 如果前置策略返回错误,不再继续
+ if (!$context->success) {
+ Logger::debug('[{}-Node] 前置策略拦截 error={}', [
+ $this->getCode(),
+ $context->errorMessage,
+ ]);
+ return $context;
+ }
+
+
+ // 如果不能处理当前步骤,传递给下一个节点
+ if (!$this->canHandle($context)) {
+ Logger::debug('[{}-Node] 不能处理当前步骤,跳过', [$this->getCode()]);
+ return $this->passToNext($context);
+ }
+
+ // 输出当前节点
+ Logger::debug('[{}-Node] 开始处理 step={} batch={}', [
+ $this->getCode(),
+ $context->currentStep,
+ $context->batchNo ?: '-',
+ ]);
+
+
+ // 执行节点具体处理逻辑
+ $context = $this->doHandle($context);
+
+ Logger::debug('[{}-Node] 处理完成 step={} batch={} success={}', [
+ $this->getCode(),
+ $context->currentStep,
+ $context->batchNo ?: '-',
+ $context->success,
+ ]);
+
+ // 执行后置策略
+ $context = $this->executeAfterStrategies($context);
+ // 后置策略拦截
+ if (!$context->success) {
+ Logger::debug('[{}-Node] 后置策略拦截 error={}', [
+ $this->getCode(),
+ $context->errorMessage,
+ ]);
+ return $context;
+ }
+
+ $nextNode = $this->getNext();
+ // 跳过节点逻辑
+ for ($i = 0; $i < $context->skipNodeCount; $i++) {
+ Logger::debug('[{}-Node] 跳过节点 code={}', [$this->getCode(), $nextNode->getCode()]);
+ $nextNode = $nextNode->getNext();
+ }
+
+ // 传递给下一个节点
+ return empty($nextNode) ? $context : $nextNode->handle($context);
+ }
+
+ /**
+ * @return array 需要的前置节点列表
+ */
+ public function getRequiredNodes($default = []): array
+ {
+ return (!empty($this->getConfig()->required)) ? $this->getConfig()->required : $default;
+ }
+
+ /**
+ * 是否是需要的前置节点
+ * @param string $currentStep
+ * @param array $default
+ * @return bool
+ */
+ public function isRequiredNode(string $currentStep, array $default = []): bool
+ {
+ return in_array($currentStep, $this->getRequiredNodes($default));
+ }
+
+ /**
+ * 当前刷的读卡器类型,是否和当前节点的配置匹配
+ */
+ public function isMatchReaderType(ProcessContext $context): bool
+ {
+ return $this->getCode() === $context->readerType;
+ }
+
+ /**
+ * 传递给下一个节点
+ */
+ protected function passToNext(ProcessContext $context): ProcessContext
+ {
+ if ($this->next !== null) {
+ return $this->next->handle($context);
+ }
+ return $context;
+ }
+
+ /**
+ * 停止传递给下一个节点
+ */
+ protected function stopNext(ProcessContext $context): ProcessContext
+ {
+ $context->skipNodeCount = count($this->getRemainingNodes());
+ return $context;
+ }
+
+ /**
+ * 获取剩余节点
+ */
+ protected function getRemainingNodes(): array
+ {
+ $remainingNodes = [];
+ $node = $this->next;
+ while ($node !== null) {
+ $remainingNodes[] = $node;
+ $node = $node->getNext();
+ }
+ return $remainingNodes;
+ }
+
+ /**
+ * 执行前置策略
+ *
+ * 在节点核心逻辑执行之前,按顺序执行所有标记为 'before' 阶段的策略
+ *
+ * 执行流程:
+ * 1. 遍历所有已注册的策略
+ * 2. 筛选出阶段为 'before' 的策略
+ * 3. 依次执行策略的 execute 方法
+ * 4. 如果某个策略导致上下文错误(!$context->isSuccess()),立即中断后续策略执行
+ *
+ * 前置策略的应用:
+ * - 时间验证:检查步骤执行时间是否符合要求
+ * - 权限检查:验证操作员是否有权限执行该步骤
+ * - 状态校验:确认流程状态是否允许进入当前步骤
+ * - 数据准备:为节点处理准备必要的数据
+ *
+ * @param ProcessContext $context 流程上下文
+ *
+ * @return ProcessContext 经过策略处理后的上下文
+ * - 如果策略执行成功,返回修改后的上下文
+ * - 如果策略执行失败,返回包含错误信息的上下文
+ *
+ * @see ProcessStrategyInterface::execute() 策略执行接口
+ * @see ProcessStrategyInterface::getPhase() 获取策略执行阶段
+ */
+ protected function executeBeforeStrategies(ProcessContext $context): ProcessContext
+ {
+ foreach ($this->strategies as $strategy) {
+ if ($strategy->getPhase() === 'before') {
+ $context = $strategy->execute($context, $this);
+ if (!$context->success) {
+ break;
+ }
+ }
+ }
+ return $context;
+ }
+
+ /**
+ * 执行后置策略
+ */
+ protected function executeAfterStrategies(ProcessContext $context): ProcessContext
+ {
+ foreach ($this->strategies as $strategy) {
+ if ($strategy->getPhase() === 'after') {
+ Logger::debug('[{}-Node] 执行后置策略 code={}', [$this->getCode(), $strategy::class]);
+ $context = $strategy->execute($context, $this);
+ }
+ }
+ return $context;
+ }
+
+ /**
+ * 具体的处理逻辑,由子类实现
+ */
+ abstract protected function doHandle(ProcessContext $context): ProcessContext;
+
+ /**
+ * 是否启用
+ */
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
+
+ /**
+ * 设置是否启用
+ */
+ public function setEnabled(bool $enabled): ProcessNodeInterface
+ {
+ $this->enabled = $enabled;
+ return $this;
+ }
+
+ /**
+ * 添加策略
+ */
+ public function addStrategy(ProcessStrategyInterface $strategy): self
+ {
+ $this->strategies[] = $strategy;
+ return $this;
+ }
+
+ /**
+ * 设置配置
+ */
+ public function setConfig(StepConfig $config): self
+ {
+ $this->config = $config;
+ return $this;
+ }
+
+ /**
+ * 获取配置
+ */
+ public function getConfig(): StepConfig
+ {
+ return $this->config;
+ }
+
+ /**
+ * 判断当前节点是否能处理该步骤
+ * 默认实现:检查当前步骤是否匹配节点编码
+ */
+ public function canHandle(ProcessContext $context): bool
+ {
+ return $context->currentStep === $this->getCode();
+ }
+
+ /**
+ * 获取当前对象地址的哈希值
+ */
+ public function _hash(): string
+ {
+ return spl_object_hash($this);
+ }
+
+ /**
+ * 获取节点名称
+ */
+ abstract static public function getName(): string;
+
+ /**
+ * 获取节点编码
+ */
+ abstract public function getCode(): string;
+}
diff --git a/app/flow/nodes/CloseNode.php b/app/flow/nodes/CloseNode.php
new file mode 100644
index 0000000..46c7646
--- /dev/null
+++ b/app/flow/nodes/CloseNode.php
@@ -0,0 +1,68 @@
+success || $context->needDatabaseOperation || !empty($context->voiceMessage)) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * 具体处理逻辑:最后节点处理
+ */
+ protected function doHandle(ProcessContext $context): ProcessContext
+ {
+ if (!$context->success || $context->needDatabaseOperation || !empty($context->voiceMessage)) return $context;
+ // 无节点命中
+ Logger::debug('当前刷卡无节点命中 currentStep={} readerType={} expectedNextStep={}', [
+ $context->currentStep ?: '(空)',
+ $context->readerType,
+ $context->expectedNextStep
+ ]);
+ // 如果有预期的下一步,则返回错误
+ if (!empty($context->expectedNextStep) && $context->expectedNextStep != VoiceMessage::NONE) {
+ Logger::debug("节点期望: {$context->expectedNextStep->value}");
+ return $context->setError($context->expectedNextStep);
+ }
+ // 异常流程
+ Logger::error("异常流程,所有节点处理完成,无匹配节点并且无预期的下一步");
+ $context->setError(VoiceMessage::UNKNOWN_ERROR);
+ return $context;
+ }
+}
diff --git a/app/flow/nodes/DisinfectNode.php b/app/flow/nodes/DisinfectNode.php
new file mode 100644
index 0000000..c7ce8a9
--- /dev/null
+++ b/app/flow/nodes/DisinfectNode.php
@@ -0,0 +1,66 @@
+isMatchReaderType($context)) {
+ if ($context->currentStep === RinseNode::getName()) {
+ $context->expectedNextStep = VoiceMessage::PLEASE_SWIPE_DISINFECT;
+ }
+ return false;
+ }
+
+ // 上一个步骤必须是漂洗 或者 晨洗
+ if (!$this->isRequiredNode($context->currentStep, [RinseNode::getName(), MorningWashNode::getName()])) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * 具体处理逻辑
+ */
+ protected function doHandle(ProcessContext $context): ProcessContext
+ {
+ // 更新步骤
+ $context->currentStep = '消毒';
+
+
+ $context->needDatabaseOperation = true;
+ $context->dbOperation = DbOperationType::INSERT;
+ $context->needWebSocketNotify = true;
+
+ return $context;
+ }
+}
diff --git a/app/flow/nodes/DryNode.php b/app/flow/nodes/DryNode.php
new file mode 100644
index 0000000..ebbeb9d
--- /dev/null
+++ b/app/flow/nodes/DryNode.php
@@ -0,0 +1,71 @@
+isMatchReaderType($context)) {
+ if ($context->currentStep === FinalRinseNode::getName()) {
+ if (!$context->success) Logger::debug("[DryNode] 刷卡错误,当前步骤是终末漂洗,但是刷的读卡器类型不是终末漂洗,对用户进行语音提示刷终末漂洗读卡器");
+ $context->expectedNextStep = VoiceMessage::PLEASE_SWIPE_DRY;
+ }
+ return false;
+ }
+
+ // 上一个步骤必须是终末漂洗
+ if (!$this->isRequiredNode($context->currentStep, [FinalRinseNode::getName()])) {
+ $context->expectedNextStep = VoiceMessage::PLEASE_SWIPE_DISINFECT;
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * 具体处理逻辑
+ */
+ protected function doHandle(ProcessContext $context): ProcessContext
+ {
+ // 更新步骤
+ $context->currentStep = '干燥';
+
+ $context->needDatabaseOperation = true;
+ $context->dbOperation = DbOperationType::INSERT;
+ $context->needWebSocketNotify = true;
+
+ return $context;
+ }
+}
diff --git a/app/flow/nodes/DuplicateCheckNode.php b/app/flow/nodes/DuplicateCheckNode.php
new file mode 100644
index 0000000..f236a97
--- /dev/null
+++ b/app/flow/nodes/DuplicateCheckNode.php
@@ -0,0 +1,52 @@
+previousAction->process_name === $context->readerType;
+ }
+
+ /**
+ * 具体处理逻辑:检查重复操作
+ */
+ protected function doHandle(ProcessContext $context): ProcessContext
+ {
+ $context->setError(VoiceMessage::DUPLICATE_SWIPING);
+ return $context;
+ }
+}
diff --git a/app/flow/nodes/EndNode.php b/app/flow/nodes/EndNode.php
new file mode 100644
index 0000000..6aed2d8
--- /dev/null
+++ b/app/flow/nodes/EndNode.php
@@ -0,0 +1,69 @@
+isMatchReaderType($context)) {
+ if ($context->currentStep === DryNode::getName() && $context->currentStep === FinalRinseNode::getName()) {
+ $context->expectedNextStep = VoiceMessage::PLEASE_SWIPE_END;
+ }
+ return false;
+ }
+
+ // 上一个步骤必须是干燥、终末漂洗或机洗
+ $validSteps = ['干燥', '终末漂洗', '机洗'];
+ if ($this->isRequiredNode($context->currentStep, ['干燥', '终末漂洗', '机洗'])) {
+ if ($context->currentStep === FinalRinseNode::getName()) {
+ $context->expectedNextStep = VoiceMessage::PLEASE_SWIPE_WASH;
+ }
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * 具体处理逻辑
+ */
+ protected function doHandle(ProcessContext $context): ProcessContext
+ {
+ // 更新步骤
+ $context->currentStep = '结束';
+
+ $context->needDatabaseOperation = true;
+ $context->dbOperation = DbOperationType::INSERT;
+ $context->needWebSocketNotify = true;
+
+ return $context;
+ }
+}
diff --git a/app/flow/nodes/FinalRinseNode.php b/app/flow/nodes/FinalRinseNode.php
new file mode 100644
index 0000000..779bf22
--- /dev/null
+++ b/app/flow/nodes/FinalRinseNode.php
@@ -0,0 +1,66 @@
+isMatchReaderType($context)) {
+ if ($context->currentStep === DisinfectNode::getName()) {
+ if (!$context->success) Logger::debug("[FinalRinseNode] 刷卡错误,当前步骤是消毒,但是刷的读卡器类型不是消毒,对用户进行语音提示刷消毒读卡器");
+ $context->expectedNextStep = VoiceMessage::PLEASE_SWIPE_FINAL_RINSE;
+ }
+ return false;
+ }
+
+ // 上一个步骤必须是消毒或机洗
+ return $this->isRequiredNode($context->currentStep, ['消毒', '机洗']);
+ }
+
+ /**
+ * 具体处理逻辑
+ */
+ protected function doHandle(ProcessContext $context): ProcessContext
+ {
+ // 更新步骤
+ $context->currentStep = '终末漂洗';
+
+
+ $context->needDatabaseOperation = true;
+ $context->dbOperation = DbOperationType::INSERT;
+ $context->needWebSocketNotify = true;
+
+ return $context;
+ }
+}
diff --git a/app/flow/nodes/MachineWashNode.php b/app/flow/nodes/MachineWashNode.php
new file mode 100644
index 0000000..b8038f6
--- /dev/null
+++ b/app/flow/nodes/MachineWashNode.php
@@ -0,0 +1,81 @@
+isMatchReaderType($context)) {
+ if ($context->currentStep === WashNode::getName()) {
+ $context->expectedNextStep = VoiceMessage::PLEASE_SWIPE_MACHINE_WASH;
+ }
+ return false;
+ }
+
+ // 需要晨洗但未完成:提示先进行晨洗
+ if ($context->needMorningWash && !$context->morningWashed) {
+ $context->expectedNextStep = VoiceMessage::PLEASE_SWIPE_MORNING_WASH;
+ return false;
+ }
+
+ // 机洗可以在多个步骤后执行:空步骤(新流程)、结束、内镜取出、清洗,晨洗
+ if (!$this->isRequiredNode($context->currentStep, ['', '结束', '内镜取出', '清洗', MachineWashNode::getName()])) {
+ if ($context->currentStep === EndNode::getName()) $context->expectedNextStep = VoiceMessage::PLEASE_SWIPE_MACHINE_WASH;
+ else $context->expectedNextStep = VoiceMessage::PLEASE_SWIPE_END;
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * 具体处理逻辑
+ */
+ protected function doHandle(ProcessContext $context): ProcessContext
+ {
+ // 设置流程类型为机洗
+ $context->processType = '机洗';
+
+ // 更新步骤
+ $context->currentStep = '机洗';
+
+
+ $context->needDatabaseOperation = true;
+ $context->dbOperation = DbOperationType::INSERT;
+ $context->needWebSocketNotify = true;
+
+ // 更新批次为机洗,
+ $context->processType = '机洗';
+ $context->dbOperation = DbOperationType::UPDATE;
+
+ return $context;
+ }
+}
diff --git a/app/flow/nodes/MorningWashNode.php b/app/flow/nodes/MorningWashNode.php
new file mode 100644
index 0000000..2fffa06
--- /dev/null
+++ b/app/flow/nodes/MorningWashNode.php
@@ -0,0 +1,75 @@
+needMorningWash || $context->morningWashed) {
+ return false;
+ }
+
+ // 检查当前读卡器类型是否匹配
+ if (!$this->isRequiredNode($context->readerType, ['漂洗', '机洗'])){
+ $context->expectedNextStep = VoiceMessage::PLEASE_SWIPE_MORNING_WASH;
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * 具体处理逻辑
+ */
+ protected function doHandle(ProcessContext $context): ProcessContext
+ {
+ Logger::debug("处理晨洗节点");
+ // 标记晨洗已开始
+ $context->morningWashed = true;
+
+ // 设置流程类型
+ if ($context->readerType === '机洗') {
+ $context->processType = '机洗(晨洗)';
+ } else {
+ $context->processType = '手工洗(晨洗)';
+ }
+
+ // 更新当前步骤
+ $context->currentStep = self::getName();
+
+ $context->needDatabaseOperation = true;
+ $context->dbOperation = DbOperationType::INSERT;
+ $context->needWebSocketNotify = true;
+
+ return $context;
+ }
+}
diff --git a/app/flow/nodes/ProcessNodeInterface.php b/app/flow/nodes/ProcessNodeInterface.php
new file mode 100644
index 0000000..c2116b2
--- /dev/null
+++ b/app/flow/nodes/ProcessNodeInterface.php
@@ -0,0 +1,64 @@
+isMatchReaderType($context)) {
+ // 当前步骤是清洗且读卡器不符:说明清洗完了应该刷漂洗
+ if ($context->currentStep === WashNode::getName()) {
+ $context->expectedNextStep = VoiceMessage::PLEASE_SWIPE_RINSE;
+ }
+ return false;
+ }
+
+ // 上一个步骤必须是清洗
+ if (!$this->isRequiredNode($context->currentStep, [WashNode::getName()])) {
+ $context->expectedNextStep = VoiceMessage::PLEASE_SWIPE_WASH;
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * 具体处理逻辑
+ */
+ protected function doHandle(ProcessContext $context): ProcessContext
+ {
+ // 更新步骤
+ $context->currentStep = '漂洗';
+
+
+ $context->needDatabaseOperation = true;
+ $context->dbOperation = DbOperationType::INSERT;
+ $context->needWebSocketNotify = true;
+
+ return $context;
+ }
+}
diff --git a/app/flow/nodes/StorageInNode.php b/app/flow/nodes/StorageInNode.php
new file mode 100644
index 0000000..e62d19d
--- /dev/null
+++ b/app/flow/nodes/StorageInNode.php
@@ -0,0 +1,96 @@
+storageSingleReader;
+
+ // 单读卡器模式不处理,由 StorageNode 统一处理
+ if ($singleReaderMode) {
+ return false;
+ }
+
+ // 读卡器不是内镜放入类型,不处理
+ if (!$this->isMatchReaderType($context)) {
+ return false;
+ }
+
+ // 获取内镜当前存储状态
+ $isInStorage = $context->isInStorage ?? false;
+
+ // 如果内镜已在库中,则当前应该是出库操作,不处理
+ if ($isInStorage) {
+ Logger::debug('[StorageInNode] 内镜已在库中,转由出库节点处理');
+ return false;
+ }
+
+ // 检查前置步骤要求
+ $validSteps = ['', '结束', '内镜取出', '测漏正常', '测漏异常'];
+ if (!in_array($context->currentStep, $validSteps)) {
+ Logger::debug('[StorageInNode] 当前步骤 {} 不符合入库条件', [$context->currentStep]);
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * 具体处理逻辑
+ */
+ protected function doHandle(ProcessContext $context): ProcessContext
+ {
+ // 设置流程类型为存储
+ $context->processType = '存储';
+
+ // 更新步骤
+ $context->currentStep = self::getName();
+
+ // 标记入库状态
+ $context->isInStorage = true;
+ $context->storageInTime = date('Y-m-d H:i:s');
+
+ // 设置数据库操作
+ $context->needDatabaseOperation = true;
+ $context->dbOperation = DbOperationType::INSERT;
+ $context->needWebSocketNotify = true;
+
+ Logger::debug('[StorageInNode] 内镜入库成功 endoscope={}', [$context->endoscopeName]);
+
+ return $context;
+ }
+}
diff --git a/app/flow/nodes/StorageNode.php b/app/flow/nodes/StorageNode.php
new file mode 100644
index 0000000..3389617
--- /dev/null
+++ b/app/flow/nodes/StorageNode.php
@@ -0,0 +1,107 @@
+storageSingleReader;
+
+ // 非单读卡器模式不处理
+ if (!$singleReaderMode) {
+ return false;
+ }
+
+ // 读卡器类型必须是'内镜放入'或'内镜取出'
+ if (!in_array($context->readerType, ['内镜放入', '内镜取出'])) {
+ return false;
+ }
+
+ $isInStorage = $context->isInStorage ?? false;
+
+ if ($isInStorage) {
+ // 内镜已在库中,执行出库
+ $validSteps = ['内镜放入', '结束'];
+ if (!in_array($context->currentStep, $validSteps)) {
+ Logger::debug('[StorageNode] 当前步骤 {} 不符合出库条件', [$context->currentStep]);
+ return false;
+ }
+ } else {
+ // 内镜不在库中,执行入库
+ $validSteps = ['', '结束', '内镜取出', '测漏正常', '测漏异常'];
+ if (!in_array($context->currentStep, $validSteps)) {
+ Logger::debug('[StorageNode] 当前步骤 {} 不符合入库条件', [$context->currentStep]);
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * 具体处理逻辑
+ * 根据 isInStorage 状态判断执行入库还是出库
+ */
+ protected function doHandle(ProcessContext $context): ProcessContext
+ {
+ // 设置流程类型为存储
+ $context->processType = '存储';
+
+ // 根据当前状态判断执行入库还是出库(canHandle 已经验证过状态)
+ if (!$context->isInStorage) {
+ // 入库操作
+ $context->currentStep = '内镜放入';
+ $context->isInStorage = true;
+ $context->storageInTime = date('Y-m-d H:i:s');
+ Logger::debug('[StorageNode] 内镜入库成功 endoscope={}', [$context->endoscopeName]);
+ } else {
+ // 出库操作
+ $context->currentStep = '内镜取出';
+ $context->isInStorage = false;
+ Logger::debug('[StorageNode] 内镜出库成功 endoscope={}', [$context->endoscopeName]);
+ }
+
+ // 设置数据库操作
+ $context->needDatabaseOperation = true;
+ $context->dbOperation = DbOperationType::INSERT;
+ $context->needWebSocketNotify = true;
+
+ return $context;
+ }
+}
diff --git a/app/flow/nodes/StorageOutNode.php b/app/flow/nodes/StorageOutNode.php
new file mode 100644
index 0000000..b979262
--- /dev/null
+++ b/app/flow/nodes/StorageOutNode.php
@@ -0,0 +1,95 @@
+storageSingleReader;
+
+ // 单读卡器模式不处理,由 StorageNode 统一处理
+ if ($singleReaderMode) {
+ return false;
+ }
+
+ // 读卡器不是内镜取出类型,不处理
+ if (!$this->isMatchReaderType($context)) {
+ return false;
+ }
+
+ // 获取内镜当前存储状态
+ $isInStorage = $context->isInStorage ?? false;
+
+ // 如果内镜不在库中,则当前应该是入库操作,不处理
+ if (!$isInStorage) {
+ Logger::debug('[StorageOutNode] 内镜不在库中,转由入库节点处理');
+ return false;
+ }
+
+ // 检查前置步骤要求:必须在库中才能出库
+ $validSteps = ['内镜放入', '结束'];
+ if (!in_array($context->currentStep, $validSteps)) {
+ Logger::debug('[StorageOutNode] 当前步骤 {} 不符合出库条件', [$context->currentStep]);
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * 具体处理逻辑
+ */
+ protected function doHandle(ProcessContext $context): ProcessContext
+ {
+ // 设置流程类型为存储
+ $context->processType = '存储';
+
+ // 更新步骤
+ $context->currentStep = self::getName();
+
+ // 标记出库状态
+ $context->isInStorage = false;
+
+ // 设置数据库操作
+ $context->needDatabaseOperation = true;
+ $context->dbOperation = DbOperationType::INSERT;
+ $context->needWebSocketNotify = true;
+
+ Logger::debug('[StorageOutNode] 内镜出库成功 endoscope={}', [$context->endoscopeName]);
+
+ return $context;
+ }
+}
diff --git a/app/flow/nodes/WashNode.php b/app/flow/nodes/WashNode.php
new file mode 100644
index 0000000..ff06c00
--- /dev/null
+++ b/app/flow/nodes/WashNode.php
@@ -0,0 +1,80 @@
+isMatchReaderType($context)) {
+ return false;
+ }
+
+ // 需要晨洗但未完成:提示先进行晨洗
+ if ($context->needMorningWash && !$context->morningWashed) {
+ $context->expectedNextStep = VoiceMessage::PLEASE_SWIPE_MORNING_WASH;
+ return false;
+ }
+
+ $validCurrentSteps = ['', '结束', '内镜取出', '内镜放入', '测漏正常', '晨洗'];
+ if (!in_array($context->currentStep, $validCurrentSteps)) {
+ // 读卡器是清洗但步骤不对(如终末漂洗时刷清洗),提示应该先刷结束
+// $context->expectedNextStep = "清洗应在流程开始时刷,当前步骤为{$context->currentStep},请先刷结束卡重新开始";
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * 具体处理逻辑
+ */
+ protected function doHandle(ProcessContext $context): ProcessContext
+ {
+ // 设置流程类型
+ if (empty($context->processType) || $context->processType === '晨洗') {
+ $context->processType = '手工洗';
+ }
+
+ // 更新步骤
+ $context->currentStep = self::getName();
+
+
+ $context->needDatabaseOperation = true;
+ $context->dbOperation = DbOperationType::INSERT;
+ $context->needWebSocketNotify = true;
+
+ return $context;
+ }
+}
diff --git a/app/flow/strategies/AbstractStrategy.php b/app/flow/strategies/AbstractStrategy.php
new file mode 100644
index 0000000..9493981
--- /dev/null
+++ b/app/flow/strategies/AbstractStrategy.php
@@ -0,0 +1,77 @@
+config = array_merge($this->config, $config);
+ }
+
+ /**
+ * 执行策略
+ */
+ public function execute(ProcessContext $context, ProcessNodeInterface $node): ProcessContext
+ {
+ if (!$this->isApplicable($context, $node)) {
+ return $context;
+ }
+ return $this->doExecute($context, $node);
+ }
+
+ /**
+ * 具体执行逻辑,由子类实现
+ */
+ abstract protected function doExecute(ProcessContext $context, ProcessNodeInterface $node): ProcessContext;
+
+ /**
+ * 获取策略执行阶段
+ */
+ public function getPhase(): string
+ {
+ return $this->phase;
+ }
+
+ /**
+ * 设置执行阶段
+ */
+ public function setPhase(string $phase): self
+ {
+ $this->phase = $phase;
+ return $this;
+ }
+
+ /**
+ * 判断策略是否适用
+ * 默认总是适用
+ */
+ public function isApplicable(ProcessContext $context, ProcessNodeInterface $node): bool
+ {
+ return true;
+ }
+
+ /**
+ * 获取配置
+ */
+ protected function getConfig(string $key, mixed $default = null): mixed
+ {
+ return $this->config[$key] ?? $default;
+ }
+}
diff --git a/app/flow/strategies/MorningWashStrategy.php b/app/flow/strategies/MorningWashStrategy.php
new file mode 100644
index 0000000..ca64cf5
--- /dev/null
+++ b/app/flow/strategies/MorningWashStrategy.php
@@ -0,0 +1,116 @@
+morningWashConfig = $config;
+ Logger::debug("MorningWashStrategy 初始化完成");
+ }
+
+ /**
+ * 执行晨洗判断
+ */
+ protected function doExecute(ProcessContext $context, ProcessNodeInterface $node): ProcessContext
+ {
+ $isEnd = $context->currentStep == '结束';
+ $needMorning = $this->checkNeedMorningWash($context) && $isEnd;
+ $context->needMorningWash = $needMorning;
+
+ // 如果不需要晨洗,标记为已完成
+ if (!$needMorning) {
+ $context->morningWashed = true;
+ }
+
+ return $context;
+ }
+
+ /**
+ * 检查是否需要晨洗
+ */
+ protected function checkNeedMorningWash(ProcessContext $context): bool
+ {
+ return match ($this->morningWashConfig->mode) {
+ MorningMode::None => false,
+ MorningMode::All => true,
+ MorningMode::StorageTime => $this->checkByStorageTime($context),
+ MorningMode::DailyFirst => $this->checkByDailyFirst($context),
+ MorningMode::SpecificTypes => $this->checkBySpecificTypes($context),
+ };
+ }
+
+ /**
+ * 根据存储时间判断(义乌模式)
+ * 普通镜柜超过阈值小时需要晨洗,无菌镜柜免晨消
+ */
+ protected function checkByStorageTime(ProcessContext $context): bool
+ {
+ $storageInTime = $context->storageInTime;
+ $lastActionType = $context->previousAction->action_type_name;
+ $lastProcessName = $context->previousAction->process_name;
+
+ // 如果最后一次操作是存储且已取出
+ if ($lastActionType === '存储' && $lastProcessName === '内镜取出') {
+ return false;
+ }
+
+ // 如果最后一次操作是存储且未取出,检查存储时间
+ if ($lastActionType === '存储' && $lastProcessName === '内镜放入' && $storageInTime) {
+ $storageHours = (time() - strtotime($storageInTime)) / 3600;
+ return $storageHours > $this->morningWashConfig->storageThreshold;
+ }
+
+ // 检查今天是否已有洗消记录
+ return $this->hasWashRecordToday($context);
+ }
+
+ /**
+ * 根据每天第一次判断(忠县模式)
+ */
+ protected function checkByDailyFirst(ProcessContext $context): bool
+ {
+ return $this->hasWashRecordToday($context);
+ }
+
+ /**
+ * 检查今天是否已有洗消记录
+ */
+ protected function hasWashRecordToday(ProcessContext $context): bool
+ {
+ return $context->todayWashRecords === 0;
+ }
+
+ /**
+ * 根据特定类型判断
+ */
+ protected function checkBySpecificTypes(ProcessContext $context): bool
+ {
+ $specificTypes = $this->morningWashConfig->getExpand('specific_types', []);
+ return in_array($context->endoscopeType, $specificTypes);
+ }
+
+ /**
+ * 获取策略名称
+ */
+ public function getName(): string
+ {
+ return '晨洗判断策略(' . $this->morningWashConfig->mode->name . ')';
+ }
+}
diff --git a/app/flow/strategies/ProcessStrategyInterface.php b/app/flow/strategies/ProcessStrategyInterface.php
new file mode 100644
index 0000000..c56ed04
--- /dev/null
+++ b/app/flow/strategies/ProcessStrategyInterface.php
@@ -0,0 +1,41 @@
+ [stepName => seconds]]
+ */
+ private array $dbDurationCache = [];
+
+ public function __construct(TimeValidationConfig $config)
+ {
+ parent::__construct([]);
+ $this->timeValidationConfig = $config;
+ Logger::debug("TimeValidationStrategy 初始化完成");
+ }
+
+ /**
+ * 从数据库获取步骤时长(按流程类型精确匹配)
+ * 优先级:数据库记录 > 构造时配置 > 内置 fallback
+ */
+ protected function getDurationFromDb(string $stepCode, string $processType): int
+ {
+ if (empty($processType)) {
+ return $this->timeValidationConfig->getDuration($stepCode, $processType);
+ }
+
+ // 整批加载并缓存,减少查询次数
+ if (!isset($this->dbDurationCache[$processType])) {
+ $this->dbDurationCache[$processType] = ProcessDurationRepository::new()
+ ->getDurationsByProcessType($processType);
+ }
+
+ $dbValue = $this->dbDurationCache[$processType][$stepCode] ?? null;
+ if ($dbValue !== null) {
+ return $dbValue;
+ }
+
+ // fallback:返回配置中的默认值
+ return $this->timeValidationConfig->getDuration($stepCode);
+ }
+
+ /**
+ * 执行时间验证
+ */
+ protected function doExecute(ProcessContext $context, ProcessNodeInterface $node): ProcessContext
+ {
+ $stepCode = $node->getCode();
+ $currentStep = $context->currentStep;
+ $processType = $context->processType;
+
+ Logger::debug("开始执行时间验证策略,步骤:{$stepCode},流程类型:{$processType}");
+ $configDuration = $this->timeValidationConfig->getDuration($stepCode,$processType);
+
+ Logger::debug("步骤:{$stepCode},流程类型:{$processType},配置时长:{$configDuration}s");
+ if ($configDuration > 0) {
+ // 配置中有明确时长,直接使用
+ $requiredDuration = $configDuration;
+ } else {
+ Logger::debug("步骤:{$stepCode},流程类型:{$processType},无配置时长,从数据库查询");
+ // 从数据库按流程类型精确查询
+ $requiredDuration = $this->getDurationFromDb($stepCode, $context->processType);
+ if ($requiredDuration > 0) {
+ $context->setStepDuration($stepCode, $requiredDuration);
+ } else {
+ // 最后使用上下文已缓存值
+ $requiredDuration = $context->getStepDuration($stepCode);
+ }
+ }
+
+ // 无需时间验证,直接放行
+ if ($requiredDuration <= 0) {
+ return $context;
+ }
+
+ // 获取上次该步骤的操作时间
+ $duration = $context->duration;
+
+ // 没有上次记录(第一次操作),允许通过
+ if (empty($duration)) {
+ Logger::debug("步骤:{$stepCode},流程类型:{$processType},无上次记录,允许通过");
+ return $context;
+ }
+
+ if ($duration < $requiredDuration) {
+ Logger::debug("[{$stepCode}] 时间不足,剩余: {}s", [$requiredDuration - $duration]);
+ if (Config::getInstance()->blockMode){
+ $voice = VoiceMessage::NOT_ENOUGH_TIME->value;
+ $voice = str_replace('{step}', $stepCode, $voice);
+ $voice = str_replace('{time}', $requiredDuration - $duration, $voice);
+ $context->setCustomError($voice);
+ }
+ return $context;
+ }
+
+ return $context;
+ }
+
+ /**
+ * 判断策略是否适用
+ * 只有在 timeValidationConfig 中登记的步骤才参与时间验证
+ */
+ public function isApplicable(ProcessContext $context, ProcessNodeInterface $node): bool
+ {
+ if ($context->currentStep != $context->previousAction->process_name) return false;
+ if (!$this->timeValidationConfig->hasStep($node->getCode(), $context->processType)) return false;
+ return true;
+ }
+
+ /**
+ * 获取策略名称
+ */
+ public function getName(): string
+ {
+ return '时间验证策略';
+ }
+
+ /**
+ * 手动设置步骤时长(覆盖 fallback,不影响数据库缓存)
+ */
+ public function setStepDuration(string $stepCode, int $seconds): self
+ {
+ $this->timeValidationConfig->setDuration($stepCode, $seconds);
+ return $this;
+ }
+}
diff --git a/app/flow/strategies/VoiceGenerationStrategy.php b/app/flow/strategies/VoiceGenerationStrategy.php
new file mode 100644
index 0000000..e68b34f
--- /dev/null
+++ b/app/flow/strategies/VoiceGenerationStrategy.php
@@ -0,0 +1,173 @@
+voiceTemplatesConfig = $config;
+ Logger::debug("VoiceGenerationStrategy 初始化完成");
+ }
+
+ /**
+ * 执行语音生成
+ */
+ protected function doExecute(ProcessContext $context, ProcessNodeInterface $node): ProcessContext
+ {
+ // 如果存在流程中自定义语音,就直接输出
+ if (!empty($context->voiceMessage)) {
+ Logger::warn(
+ "流程中存在自定义语音或存在多次设置语音,语音应该在 VoiceGenerationStrategy 策略中配置,否则不能拦截与自定义配置",
+ new IllegalUsageException("In the existing process, there is custom voice, which should be configured in the VoiceGenerationStrategy strategy; otherwise, it cannot be intercepted and customized")
+ );
+ $context->setVoice($context->voiceMessage);
+ return $context;
+ }
+
+ // 如果已经有错误,生成错误语音
+ // 否则生成正常流程语音
+ $voice = !$context->success ? $this->generateErrorVoice($context) : $this->generateNormalVoice($context, $node);
+
+
+ // 应用语音模板
+ foreach ($context->voiceTemplateParams as $key => $val) {
+ $replaceVal = match (true) {
+ is_array($val) => implode(',', $val),
+ is_bool($val) => $val ? 'true' : 'false',
+ is_null($val) => '',
+ default => (string) $val
+ };
+ $voice = str_replace('{' . $key . '}', $replaceVal, $voice);
+ }
+ Logger::debug("应用语音模板后的内容: {$voice}");
+ $context->setVoice($voice);
+
+ return $context;
+ }
+
+ /**
+ * 生成正常流程语音
+ */
+ protected function generateNormalVoice(ProcessContext $context, ProcessNodeInterface $node): string
+ {
+ $stepCode = $node->getCode();
+ $stepName = $node->getName();
+ $processType = $context->processType;
+
+ // 根据流程类型选择模板
+ $templateKey = $this->getTemplateKey($processType, $stepCode, $context);
+ $templates = $this->voiceTemplatesConfig->getTemplates($templateKey);
+ if (empty($templates)) {
+ $templates = $this->voiceTemplatesConfig->normalWash;
+ }
+
+ // 优先使用自定义步骤语音(直接存储在对应模板key中)
+ $voice = $templates[$stepCode] ?? $stepCode . '完成';
+
+ // 添加提醒信息
+ $remind = $this->getRemindMessage($context);
+ if ($remind) {
+ $voice .= $remind;
+ }
+
+ return $voice;
+ }
+
+ /**
+ * 生成错误语音
+ */
+ protected function generateErrorVoice(ProcessContext $context): string
+ {
+ $errorMessage = $context->errorMessage;
+ $errorTemplates = $this->voiceTemplatesConfig->voiceMessage;
+
+ $errorMsg = $errorTemplates[$errorMessage->name] ?? $errorMessage->value;
+ if (empty($errorMsg)) {
+ $errorMsg = $context->voiceMessage;
+ Logger::debug("错误信息配置未命中,使用自定义语音: {$errorMsg}");
+ } else {
+ Logger::debug("错误信息配置命中: {$errorMsg}");
+ }
+
+ if (empty($errorMsg)) {
+ Logger::error("配置文件与枚举信息中未找匹配到错误信息");
+ }
+
+ return $errorMsg ?: '刷卡错误';
+ }
+
+ /**
+ * 流程类型到模板键的映射
+ */
+ protected const array PROCESS_TYPE_MAP = [
+ '机洗' => 'machine_wash',
+ '机洗(晨洗)' => 'machine_wash',
+ '机洗(加强)' => 'machine_wash',
+ '测漏' => 'leak_test',
+ '存储' => 'storage',
+ ];
+
+ /**
+ * 获取模板键
+ */
+ protected function getTemplateKey(string $processType, string $stepCode, ProcessContext $context): string
+ {
+ // 晨洗流程中的清洗/机洗步骤
+ if ($context->needMorningWash) {
+ return 'morning_wash';
+ }
+
+ // 从映射表中获取,默认返回 normal_wash
+ return self::PROCESS_TYPE_MAP[$processType] ?? 'normal_wash';
+ }
+
+ /**
+ * 获取提醒信息
+ */
+ protected function getRemindMessage(ProcessContext $context): string
+ {
+ $reminds = [];
+
+ if ($context->needLeakTestRemind) {
+ $reminds[] = ',清洗开始前,请测漏';
+ }
+
+ if ($context->needStorageRemind) {
+ $reminds[] = ',未登记取出';
+ }
+
+ return implode('', $reminds);
+ }
+
+ /**
+ * 判断策略是否适用
+ */
+ public function isApplicable(ProcessContext $context, ProcessNodeInterface $node): bool
+ {
+ return true;
+ }
+
+ /**
+ * 获取策略名称
+ */
+ public function getName(): string
+ {
+ return '语音生成策略';
+ }
+}
diff --git a/webman/app/functions.php b/app/functions.php
similarity index 100%
rename from webman/app/functions.php
rename to app/functions.php
diff --git a/webman/app/middleware/StaticFile.php b/app/middleware/StaticFile.php
similarity index 100%
rename from webman/app/middleware/StaticFile.php
rename to app/middleware/StaticFile.php
diff --git a/app/model/EctActions.php b/app/model/EctActions.php
new file mode 100644
index 0000000..52fe7ab
--- /dev/null
+++ b/app/model/EctActions.php
@@ -0,0 +1,62 @@
+rawBytes;
+ }
+ }
+
+ /**
+ * @var string 原始报文的十六进制字符串
+ */
+ public string $hexString {
+ get {
+ return $this->hexString;
+ }
+ }
+
+ /**
+ * @var int 十六进制字符串长度
+ */
+ public int $hexLength {
+ get {
+ return $this->hexLength;
+ }
+ }
+
+ /**
+ * @var PacketType 报文类型 0:未知 1:有线读卡器 2:无线读卡器 3:电流采集器 4:新版电流采集器
+ */
+ public PacketType $hexType = PacketType::UNKNOWN {
+ get {
+ return $this->hexType;
+ }
+ }
+
+ /**
+ * @var bool 是否匹配成功
+ */
+ public bool $isMatched = false {
+ get {
+ return $this->isMatched;
+ }
+ }
+
+ /**
+ * @throws UnsupportedPacketException 当前报文未解析成功时抛出异常
+ * @var string 卡号
+ */
+ public string $card = '' {
+ get {
+ if (!$this->isMatched) throw new UnsupportedPacketException();
+ return $this->card;
+ }
+ }
+
+ /**
+ * @throws UnsupportedPacketException 当前报文未解析成功时抛出异常
+ * @var string 读卡器编号
+ */
+ public string $reader = '' {
+ get {
+ if (!$this->isMatched) throw new UnsupportedPacketException();
+ return $this->reader;
+ }
+ }
+
+ /**
+ * @throws UnsupportedPacketException 当前报文未解析成功时抛出异常
+ * @var string 检测设备/站号
+ */
+ public string $detectionDevice = '' {
+ get {
+ if (!$this->isMatched) throw new UnsupportedPacketException();
+ return $this->detectionDevice;
+ }
+ }
+
+ /**
+ * @throws UnsupportedPacketException 当前报文未解析成功时抛出异常
+ * @var string 网关编号
+ */
+ public string $gateway = '' {
+ get {
+ if (!$this->isMatched) throw new UnsupportedPacketException();
+ return $this->gateway;
+ }
+ }
+
+ /**
+ * @throws UnsupportedPacketException 当前报文未解析成功时抛出异常
+ * @var string 通道1数据
+ */
+ public string $channel1 = '' {
+ get {
+ if (!$this->isMatched) throw new UnsupportedPacketException();
+ return $this->channel1;
+ }
+ }
+
+ /**
+ * @throws UnsupportedPacketException 当前报文未解析成功时抛出异常
+ * @var string 通道2数据
+ */
+ public string $channel2 = '' {
+ get {
+ if (!$this->isMatched) throw new UnsupportedPacketException();
+ return $this->channel2;
+ }
+ }
+
+ /**
+ * @throws UnsupportedPacketException 当前报文未解析成功时抛出异常
+ * @var string 通道3数据
+ */
+ public string $channel3 = '' {
+ get {
+ if (!$this->isMatched) throw new UnsupportedPacketException();
+ return $this->channel3;
+ }
+ }
+
+ /**
+ * @throws UnsupportedPacketException 当前报文未解析成功时抛出异常
+ * @var string 通道4数据
+ */
+ public string $channel4 = '' {
+ get {
+ if (!$this->isMatched) throw new UnsupportedPacketException();
+ return $this->channel4;
+ }
+ }
+
+ /**
+ * @throws UnsupportedPacketException 当前报文未解析成功时抛出异常
+ * @var string 通道5数据
+ */
+ public string $channel5 = '' {
+ get {
+ if (!$this->isMatched) throw new UnsupportedPacketException();
+ return $this->channel5;
+ }
+ }
+
+ /**
+ * @throws UnsupportedPacketException 当前报文未解析成功时抛出异常
+ * @var string 通道6数据
+ */
+ public string $channel6 = '' {
+ get {
+ if (!$this->isMatched) throw new UnsupportedPacketException();
+ return $this->channel6;
+ }
+ }
+
+ /**
+ * @throws UnsupportedPacketException 当前报文未解析成功时抛出异常
+ * @var float 电池电量
+ */
+ public float $batteryLevel = 0.0 {
+ get {
+ if (!$this->isMatched) throw new UnsupportedPacketException();
+ return $this->batteryLevel;
+ }
+ }
+
+ public string $rawText {
+ get => $this->rawText;
+ }
+
+ public int $length{
+ get => strlen($this->rawBytes);
+ }
+
+ /**
+ * 构造函数
+ * @param string $rawBytes 原始报文字节数据
+ * @param array $parsedData 解析后的属性数据(可选)
+ */
+ public function __construct(string $rawBytes, array $parsedData = [])
+ {
+ // 初始化基础属性
+ $this->rawBytes = $rawBytes;
+ $this->hexString = strtoupper(bin2hex($rawBytes));
+ $this->hexLength = strlen($this->hexString);
+
+ // 合并解析后的属性
+ if (!empty($parsedData)) {
+ foreach ($parsedData as $key => $value) {
+ if (property_exists($this, $key)) {
+ // 处理报文类型枚举
+ if ($key === 'hexType' && is_int($value)) {
+ $this->hexType = PacketType::fromValue($value);
+ continue;
+ }
+ $this->$key = $value;
+ }
+ }
+ }
+ }
+
+ // ========== 对外暴露的读取方法 ==========
+
+ public function getRawText(): string
+ {
+ return $this->hexString;
+ }
+
+ /**
+ * 获取所有解析结果(数组形式)
+ */
+ public function toArray(): array
+ {
+ return [
+ 'hex' => $this->hexString,
+ 'len' => $this->hexLength,
+ 'hextype' => $this->hexType,
+ 'match' => $this->isMatched ? 1 : 0,
+ 'card' => $this->card,
+ 'reader' => $this->reader,
+ 'detection_device' => $this->detectionDevice,
+ 'gateway' => $this->gateway,
+ 'channel_1' => $this->channel1,
+ 'channel_2' => $this->channel2,
+ 'channel_3' => $this->channel3,
+ 'channel_4' => $this->channel4,
+ 'channel_5' => $this->channel5,
+ 'channel_6' => $this->channel6,
+ 'battery_level' => $this->batteryLevel
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/net/PacketContext.php b/app/net/PacketContext.php
new file mode 100644
index 0000000..b672019
--- /dev/null
+++ b/app/net/PacketContext.php
@@ -0,0 +1,41 @@
+ $this->connections;
+ }
+ public TcpConnection $connection {
+ get => $this->connection;
+ }
+ public Packet $packet {
+ get => $this->packet;
+ }
+
+ public string $ip {
+ get => $this->connection->getRemoteIp();
+ }
+
+ public int $port {
+ get => $this->connection->getRemotePort();
+ }
+ /**
+ * @var int 上下文创建时的时间
+ */
+ public int $createTime {
+ get {
+ return time();
+ }
+ }
+
+ public function __construct(array $connections,TcpConnection $connection, Packet $packet)
+ {
+ $this->connections = $connections;
+ $this->connection = $connection;
+ $this->packet = $packet;
+ }
+}
\ No newline at end of file
diff --git a/app/net/PacketType.php b/app/net/PacketType.php
new file mode 100644
index 0000000..1d83938
--- /dev/null
+++ b/app/net/PacketType.php
@@ -0,0 +1,52 @@
+ self::WIRED_READER,
+ 2 => self::WIRELESS_READER,
+ 3 => self::CURRENT_COLLECTOR,
+ 4 => self::NEW_CURRENT_COLLECTOR,
+ default => self::UNKNOWN,
+ };
+ }
+
+ /**
+ * 获取友好的类型名称(用于日志/展示)
+ * @return string
+ */
+ public function getName(): string
+ {
+ return match ($this) {
+ self::UNKNOWN => '未知报文',
+ self::WIRED_READER => '有线读卡器报文',
+ self::WIRELESS_READER => '无线读卡器报文',
+ self::CURRENT_COLLECTOR => '电流采集器报文',
+ self::NEW_CURRENT_COLLECTOR => '新版电流采集器报文',
+ };
+ }
+}
\ No newline at end of file
diff --git a/app/net/exception/UnsupportedPacketException.php b/app/net/exception/UnsupportedPacketException.php
new file mode 100644
index 0000000..d6e2695
--- /dev/null
+++ b/app/net/exception/UnsupportedPacketException.php
@@ -0,0 +1,35 @@
+ 3,
+ 'isMatched' => true,
+ 'card' => $matches[3],
+ 'reader' => $matches[2],
+ 'detectionDevice' => $matches[4],
+ 'channel1' => $matches[5],
+ 'channel2' => $matches[6],
+ 'channel3' => $matches[7],
+ 'channel4' => $matches[8],
+ 'channel5' => $matches[9],
+ 'channel6' => $matches[10],
+ ];
+ }
+ return ['isMatched' => false];
+ }
+}
\ No newline at end of file
diff --git a/app/net/parsers/NewCurrentCollectorParser.php b/app/net/parsers/NewCurrentCollectorParser.php
new file mode 100644
index 0000000..dba7e0e
--- /dev/null
+++ b/app/net/parsers/NewCurrentCollectorParser.php
@@ -0,0 +1,25 @@
+ 4,
+ 'isMatched' => true,
+ 'detectionDevice' => substr($hexString, 16, 2),
+ 'channel1' => substr($hexString, 24, 2),
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/net/parsers/PacketParserFactory.php b/app/net/parsers/PacketParserFactory.php
new file mode 100644
index 0000000..6113f15
--- /dev/null
+++ b/app/net/parsers/PacketParserFactory.php
@@ -0,0 +1,106 @@
+registerDefaultParsers();
+ }
+
+ /**
+ * 防止克隆(保障单例唯一性)
+ */
+ private function __clone() {}
+
+ /**
+ * 防止反序列化(保障单例唯一性)
+ */
+ public function __wakeup()
+ {
+ throw new \RuntimeException('Cannot unserialize singleton ' . self::class);
+ }
+
+ /**
+ * 获取单例实例(懒汉式:首次调用才初始化)
+ * @return self
+ */
+ public static function getInstance(): self
+ {
+ if (self::$instance === null) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ /**
+ * 注册默认解析器(抽离出来,便于扩展)
+ */
+ private function registerDefaultParsers(): void
+ {
+ $this->registerParser(new WiredReaderParser());
+ $this->registerParser(new WirelessReaderParser());
+ $this->registerParser(new CurrentCollectorParser());
+ $this->registerParser(new NewCurrentCollectorParser());
+ }
+
+ /**
+ * 注册解析器(支持动态扩展)
+ * @param PacketParserInterface $parser
+ * @return void
+ */
+ public function registerParser(PacketParserInterface $parser): void
+ {
+ $this->parsers[] = $parser;
+ }
+
+ /**
+ * 创建并解析Packet对象
+ * @param string $rawBytes 原始报文字节数据
+ * @return Packet
+ */
+ public function create(string $rawBytes): Packet
+ {
+ $hexString = strtoupper(bin2hex($rawBytes));
+ $parsedData = null;
+
+ // 遍历解析器找匹配的
+ foreach ($this->parsers as $parser) {
+ if ($parser->supports($hexString)) {
+ $parsedData = $parser->parse($hexString);
+ break;
+ }
+ }
+
+ return new Packet($rawBytes, $parsedData);
+ }
+
+ /**
+ * 创建并解析Packet对象
+ * @param string $rawBytes 原始报文字节数据
+ * @return Packet
+ */
+ public static function new(string $rawBytes): Packet
+ {
+ return self::getInstance()->create($rawBytes);
+ }
+}
\ No newline at end of file
diff --git a/app/net/parsers/PacketParserInterface.php b/app/net/parsers/PacketParserInterface.php
new file mode 100644
index 0000000..9b5b1d6
--- /dev/null
+++ b/app/net/parsers/PacketParserInterface.php
@@ -0,0 +1,23 @@
+ 1,
+ 'isMatched' => true,
+ 'reader' => $matches[1],
+ 'card' => $matches[2],
+ ];
+ }
+ return ['isMatched' => false];
+ }
+}
\ No newline at end of file
diff --git a/app/net/parsers/WirelessReaderParser.php b/app/net/parsers/WirelessReaderParser.php
new file mode 100644
index 0000000..49ded04
--- /dev/null
+++ b/app/net/parsers/WirelessReaderParser.php
@@ -0,0 +1,31 @@
+ 2,
+ 'isMatched' => true,
+ 'gateway' => $matches[1],
+ 'card' => $matches[2],
+ 'reader' => $matches[3],
+ 'batteryLevel' => (hexdec($matches[4]) + 200) / 100,
+ ];
+ }
+ return ['isMatched' => false];
+ }
+}
\ No newline at end of file
diff --git a/webman/app/process/Http.php b/app/process/Http.php
similarity index 100%
rename from webman/app/process/Http.php
rename to app/process/Http.php
diff --git a/webman/app/process/Monitor.php b/app/process/Monitor.php
similarity index 100%
rename from webman/app/process/Monitor.php
rename to app/process/Monitor.php
diff --git a/app/process/TcpServer.php b/app/process/TcpServer.php
new file mode 100644
index 0000000..57597dc
--- /dev/null
+++ b/app/process/TcpServer.php
@@ -0,0 +1,110 @@
+ 连接池,key为客户端IP,value为连接对象
+ */
+ private static array $connections = [];
+
+ public function __construct()
+ {
+ // 初始化 FlowMain
+ FlowMain::getInstance();
+ }
+
+ /**
+ * 获取连接池
+ */
+ public static function getConnections(): array
+ {
+ return self::$connections;
+ }
+
+ /**
+ * 获取链接
+ */
+ public static function getConnection(string $ip): ?TcpConnection
+ {
+ return self::$connections[$ip] ?? null;
+ }
+
+ /**
+ * 获取所有IP
+ */
+ public static function getAllIp(): array
+ {
+ return array_keys(self::$connections);
+ }
+
+ /**
+ * 获取所有连接
+ */
+ public static function getAllConnections(): array
+ {
+ return self::$connections;
+ }
+
+ /**
+ * Worker启动时触发
+ */
+ public function onWorkerStart($worker): void
+ {
+ Logger::info("TcpServer started");
+ }
+
+
+ /**
+ * 连接时触发
+ */
+ public function onConnect(TcpConnection $connection): void
+ {
+ self::$connections[$connection->getRemoteIp()] = $connection;
+ Log::info("客户端链接到主机: {$connection->getRemoteIp()}");
+ }
+
+ /**
+ * 接收数据时触发
+ */
+ public function onMessage(TcpConnection $connection, $data): void
+ {
+ $ip = $connection->getRemoteIp();
+ self::$connections[$ip] = $connection;
+ $packet = PacketParserFactory::new($data);
+ $context = new PacketContext(self::getConnections(), $connection, $packet);
+ // 格式化输出报文信息文本框
+ Logger::debug(Logger::generateTextBox([
+ "---------------------------- PACKET --------------------------------",
+ "IP Address : {$ip}",
+ "Packet Length: {$packet->length}",
+ "Hex Packet : {$packet->hexString}",
+ "Packet Type : {$packet->hexType->name}",
+ "---------------------------- DATA --------------------------------",
+ "Reader Info : {$packet->reader}",
+ "Card Info : {$packet->card}",
+ "Gateway Info : {$packet->gateway}",
+ ]));
+
+ $result = FlowMain::getInstance()->main($context);
+ $connection->send($result->getFullVoice());
+ }
+
+ /**
+ * 连接关闭时触发
+ */
+ public function onClose(TcpConnection $connection): void
+ {
+ unset(self::$connections[$connection->getRemoteIp()]);
+ Logger::info("客户端已断开连接:{$connection->getRemoteIp()}");
+ }
+}
\ No newline at end of file
diff --git a/app/repository/BaseRepository.php b/app/repository/BaseRepository.php
new file mode 100644
index 0000000..3757859
--- /dev/null
+++ b/app/repository/BaseRepository.php
@@ -0,0 +1,12 @@
+model = new EctActions();
+ }
+
+ public static function new(): static
+ {
+ return new self();
+ }
+
+ /**
+ * 从数据库查询最大的批次号
+ * 用于分布式场景下保证批次号一致性
+ *
+ * @param string $machineId
+ * @return string|null 批次号,未找到返回 null
+ */
+ public function findTodayActiveBatchNo(string $machineId): ?string
+ {
+ try {
+ // 1. 精准限定今日时间范围(避免跨天数据)
+ $todayStart = date('Y-m-d 00:00:00');
+ $todayEnd = date('Y-m-d 23:59:59');
+
+ $todayDate = date('Ymd');
+
+ $record = EctActions::where('process_name', '<>', '结束')
+ ->whereBetween('created_at', [$todayStart, $todayEnd])
+ ->where('op_batchno', 'like', $todayDate . $machineId . '%') // 匹配今日日期+指定机器ID开头的批次号
+ ->select('op_batchno')
+ ->orderBy('op_batchno', 'desc') // 降序排列,最大的序列号(批次号)排在最前
+ ->lockForUpdate()
+ ->first(); // 获取第一条(即序列号最大的)记录
+
+ // 3. 严谨的空值判断
+ if (empty($record) || empty(trim($record->op_batchno))) {
+ return null;
+ }
+
+ return (string)trim($record->op_batchno);
+ } catch (\Exception $e) {
+ // 记录异常(可选:根据项目日志规范调整)
+ Logger::error('查询最大批次号失败:', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString()
+ ]);
+ return null;
+ }
+ }
+
+ /**
+ * 查询内镜最后一条洗消操作记录
+ *
+ * @param string $endoscopeId 内镜ID
+ * @param array $excludeActionTypes 排除的操作类型(如 [0, 7, 8])
+ * @return EctActions|null
+ */
+ public function findLastAction(string $endoscopeId, array $excludeActionTypes = []): ?EctActions
+ {
+ $query = EctActions::where('endoscope_id', $endoscopeId);
+ if (!empty($excludeActionTypes)) {
+ $query->whereNotIn('action_type', $excludeActionTypes);
+ }
+ /** @var EctActions|null */
+ return $query->orderBy('action_id', 'desc')->first();
+ }
+
+ /**
+ * 查询今日洗消记录数
+ *
+ * @param string $endoscopeId 内镜ID
+ * @param array $excludeActionTypes 排除的操作类型 0诊疗 1手工洗 2机洗 3 手工洗(晨洗)4机洗(晨洗)5手工洗(加强)6机洗(加强)7测漏 8存储
+ * @return int
+ */
+ public function countTodayActions(string $endoscopeId, string $todayStart, array $excludeActionTypes = []): int
+ {
+ $todayStart = date('Y-m-d H:i:s', strtotime($todayStart));
+ $query = EctActions::where('endoscope_id', $endoscopeId)
+ ->where('created_at', '>=', $todayStart);
+ if (!empty($excludeActionTypes)) {
+ $query->whereNotIn('action_type', $excludeActionTypes);
+ }
+ // 遍历
+ $count = 0;
+ $today = date('Ymd');
+ $query->lazy()->each(function (EctActions $record) use (&$count, $today) {
+ $batchInfo = ProcessContext::parseBatchNo($record->op_batchno);
+ $dateTime = strtotime($batchInfo['date']);
+ if (empty($batchInfo['date']) || $dateTime === false) return;
+ $recordDate = date('Ymd', $dateTime);
+ if ($recordDate >= $today) {
+ $count += 1;
+ }
+ });
+ return $count;
+ }
+
+ /**
+ * 查询最后一次存储入库时间
+ *
+ * @param string $endoscopeId 内镜ID
+ * @return string|null op_starttime,未找到返回 null
+ */
+ public function findLastStorageTime(string $endoscopeId): ?string
+ {
+ $record = EctActions::where('endoscope_id', $endoscopeId)
+ ->where('action_type', 8)
+ ->where('process_name', '内镜放入')
+ ->select('op_starttime')
+ ->orderBy('action_id', 'desc')
+ ->first();
+
+ if ($record === null) {
+ return null;
+ }
+
+ return (string)$record->op_starttime;
+ }
+
+ /**
+ * 查询内镜当前是否在存储柜中
+ * 根据最后一次存储操作判断:入库则在库中,出库则不在
+ *
+ * @param string $endoscopeId 内镜ID
+ * @return bool true=在库中,false=不在库中
+ */
+ public function isEndoscopeInStorage(string $endoscopeId): bool
+ {
+ $record = EctActions::where('endoscope_id', $endoscopeId)
+ ->where('action_type', 8)
+ ->whereIn('process_name', ['内镜放入', '内镜取出'])
+ ->select('process_name', 'op_starttime')
+ ->orderBy('action_id', 'desc')
+ ->first();
+
+ if ($record === null) {
+ return false;
+ }
+
+ return $record->process_name === '内镜放入';
+ }
+
+ /**
+ * 查询最后一次存储操作记录
+ *
+ * @param string $endoscopeId 内镜ID
+ * @return array|null ['process_name' => ..., 'op_starttime' => ...] 或 null
+ */
+ public function findLastStorageAction(string $endoscopeId): ?array
+ {
+ $record = EctActions::where('endoscope_id', $endoscopeId)
+ ->where('action_type', 8)
+ ->whereIn('process_name', ['内镜放入', '内镜取出'])
+ ->select('process_name', 'op_starttime', 'action_id')
+ ->orderBy('action_id', 'desc')
+ ->first();
+
+ if ($record === null) {
+ return null;
+ }
+
+ return [
+ 'process_name' => (string)$record->process_name,
+ 'op_starttime' => (string)$record->op_starttime,
+ 'action_id' => (int)$record->action_id,
+ ];
+ }
+
+ /**
+ * 查询批次号对应的操作员信息
+ *
+ * @param string $batchNo 批次号
+ * @return array|null [字段名=> 值, ...] 或 null
+ */
+ public function findOperatorByBatchNo(string $batchNo): ?array
+ {
+ $record = EctActions::where('op_batchno', $batchNo)
+ ->whereNotNull('opuser_id')
+ ->select('opuser_id', 'opuser_name', 'opuser_rfid', 'opuser_type')
+ ->first();
+
+ if ($record === null) {
+ return null;
+ }
+
+ return [
+ 'id' => (string)$record->opuser_id,
+ 'name' => (string)$record->opuser_name,
+ 'rfid' => (string)$record->opuser_rfid,
+ 'type' => (int)$record->opuser_type,
+ ];
+ }
+
+ /**
+ * 插入一条洗消记录
+ *
+ * @param array $data 字段数组
+ * @return EctActions
+ */
+ public function insert(array $data): EctActions
+ {
+ return EctActions::create($data);
+ }
+
+ /**
+ * 从 ProcessContext 保存洗消记录
+ *
+ * @param ProcessContext $context 流程上下文
+ * @param int $opuserType 操作员类型
+ * @return EctActions|null
+ */
+ public function saveFromContext(ProcessContext $context, int $opuserType): ?EctActions
+ {
+ $op_endtime = null;
+ $process_order = 1;
+ if ($context->currentStep === '结束') {
+ $op_endtime = date('Y-m-d H:i:s');
+ }
+ if (!empty($context->previousAction)) {
+ $process_order = (int)($context->previousAction->process_order ?? 1);
+ }
+ if (in_array(DbOperationType::INSERT, $context->dbOperation)) {
+ return $this->insert([
+ 'op_batchno' => $context->batchNo,
+ 'action_type' => $this->mapActionType($context->processType),
+ 'action_type_name' => $context->processType,
+ 'process_name' => $context->currentStep,
+ 'process_order' => $process_order + 1,
+ 'op_morning' => $context->needMorningWash ? 1 : 0,
+ 'op_enhance' => $context->needEnhanceWash ? 1 : 0,
+ 'reader_id' => (int)$context->readerId ?: null,
+ 'reader_no' => $context->readerNo ?: null,
+ 'opuser_type' => $opuserType,
+ 'opuser_id' => (int)$context->operatorId ?: null,
+ 'opuser_rfid' => $context->operatorRfid ?: null,
+ 'opuser_name' => $context->operatorName ?: null,
+ 'endoscope_id' => (int)$context->endoscopeId ?: null,
+ 'endoscope_rfid' => $context->cardNo ?: null,
+ 'endoscope_name' => $context->endoscopeName ?: null,
+ 'op_starttime' => $context->actionStartTime ?: date('Y-m-d H:i:s'),
+ 'op_endtime' => $op_endtime,
+ 'op_duration' => $context->duration ?: null,
+ 'created_at' => date('Y-m-d H:i:s'),
+ ]);
+ } elseif (in_array(DbOperationType::UPDATE, $context->dbOperation)) {
+ EctActions::where('op_batchno', $context->batchNo)
+ ->where('endoscope_id', $context->endoscopeId)
+ ->update([
+ 'action_type' => $this->mapActionType($context->processType),
+ 'action_type_name' => $context->processType,
+ ]);
+ }
+ return null;
+ }
+
+ /**
+ * 映射流程类型到 action_type
+ * 0诊疗 1手工洗 2机洗 3 手工洗(晨洗)4机洗(晨洗)5手工洗(加强)6机洗(加强)
+ */
+ protected function mapActionType(string $processType): int
+ {
+ $mapping = [
+ '诊疗' => 0,
+ '手工洗' => 1,
+ '手工洗(晨洗)' => 3,
+ '手工洗(加强)' => 5,
+ '机洗' => 2,
+ '机洗(晨洗)' => 4,
+ '机洗(加强)' => 6,
+ '测漏' => 7,
+ '存储' => 8,
+ ];
+ return $mapping[$processType] ?? 1;
+ }
+
+ /**
+ * 更新批次号最后一条记录的结束时间和时长
+ *
+ * @param string $batchNo 批次号
+ * @param string $endTime 结束时间
+ * @param int $duration 时长(秒)
+ * @return int 影响行数
+ */
+ public function updateEndTime(string $batchNo, string $endTime, int $duration): int
+ {
+ return EctActions::where('op_batchno', $batchNo)
+ ->orderBy('action_id', 'desc')
+ ->limit(1)
+ ->update([
+ 'op_endtime' => $endTime,
+ 'op_duration' => $duration,
+ ]);
+ }
+}
diff --git a/app/repository/EndoscopeRepository.php b/app/repository/EndoscopeRepository.php
new file mode 100644
index 0000000..5dfc740
--- /dev/null
+++ b/app/repository/EndoscopeRepository.php
@@ -0,0 +1,38 @@
+model = new EctMetaEndoscope();
+ }
+
+ public static function new(): static
+ {
+ return new self();
+ }
+
+ /**
+ * 通过 RFID 卡号查询内镜信息
+ * 同时匹配主卡号(endoscope_rfid)和备用卡号(rfid3)
+ *
+ * @param string $cardNo RFID 卡号(大写)
+ * @return EctMetaEndoscope|null
+ */
+ public function findByCardNo(string $cardNo): ?EctMetaEndoscope
+ {
+ $cardNo = strtoupper($cardNo);
+ /** @var EctMetaEndoscope|null */
+ return EctMetaEndoscope::where('endoscope_rfid', $cardNo)
+ ->orWhere('rfid3', $cardNo)
+ ->first();
+ }
+}
diff --git a/app/repository/ProcessDurationRepository.php b/app/repository/ProcessDurationRepository.php
new file mode 100644
index 0000000..cabec53
--- /dev/null
+++ b/app/repository/ProcessDurationRepository.php
@@ -0,0 +1,78 @@
+model = new EctMetaProcess();
+ }
+
+ public static function new(): BaseRepository
+ {
+ return new self();
+ }
+
+ public function getProcessDurations(): array
+ {
+ $result = [];
+
+ $processes = EctMetaProcess::all();
+ foreach ($processes as $process) {
+ $processType = $process->process_type ?? '';
+ $processName = $process->process_name ?? '';
+ $processDuration = $process->process_duration ?? 0;
+
+ if (!isset($result[$processType])) $result[$processType] = [];
+ $result[$processType][$processName] = $processDuration;
+ }
+
+ return $result;
+ }
+
+ /**
+ * 查询某流程类型下某步骤的时长(秒)
+ *
+ * @param string $processType 流程类型,如"手工洗"、"机洗(晨洗)"等
+ * @param string $processName 步骤名称,如"清洗"、"消毒"等
+ * @return int|null 返回 process_duration(秒),未找到返回 null
+ */
+ public function getDurationByProcessTypeAndName(string $processType, string $processName): ?int
+ {
+ $record = EctMetaProcess::where('process_type', $processType)
+ ->where('process_name', $processName)
+ ->where('status', 1)
+ ->select('process_duration')
+ ->first();
+
+ if ($record === null) {
+ return null;
+ }
+
+ return (int) $record->process_duration;
+ }
+
+ /**
+ * 查询某流程类型下所有步骤的时长,返回 [步骤名称 => 秒数] 映射
+ *
+ * @param string $processType 流程类型,如"手工洗"、"机洗(晨洗)"等
+ * @return array [process_name => process_duration]
+ */
+ public function getDurationsByProcessType(string $processType): array
+ {
+ $records = EctMetaProcess::where('process_type', $processType)
+ ->where('status', 1)
+ ->select('process_name', 'process_duration')
+ ->get();
+
+ $result = [];
+ foreach ($records as $record) {
+ $result[$record->process_name] = (int) $record->process_duration;
+ }
+
+ return $result;
+ }
+}
\ No newline at end of file
diff --git a/app/repository/ReaderRepository.php b/app/repository/ReaderRepository.php
new file mode 100644
index 0000000..b4f028c
--- /dev/null
+++ b/app/repository/ReaderRepository.php
@@ -0,0 +1,69 @@
+model = new EctMetaRfidreader();
+ }
+
+ public static function new(): static
+ {
+ return new self();
+ }
+
+ /**
+ * 通过读卡器编号查询读卡器所在步骤信息
+ *
+ * 联合查询路径:
+ * ect_meta_rfidreader.reader_no
+ * → ect_meta_facilities.reader_id
+ * → ect_meta_process.process_id
+ * → process_name(步骤名称)
+ *
+ * @param string $readerNo 读卡器编号(大写)
+ * @return array{readerId: string, readerType: string}|null
+ * readerId = reader_id(读卡器ID)
+ * readerType = process_name(该读卡器所在步骤,如"清洗")
+ */
+ public function findReaderInfo(string $readerNo): ?array
+ {
+ $readerNo = strtoupper($readerNo);
+
+ $reader = EctMetaRfidreader::where('reader_no', $readerNo)
+ ->select('reader_id')
+ ->first();
+
+ if ($reader === null) {
+ return null;
+ }
+
+ $readerId = (string) $reader->reader_id;
+
+ $facility = EctMetaFacilities::where('reader_id', $readerId)
+ ->select('process_id')
+ ->first();
+
+ if ($facility === null) {
+ return ['readerId' => $readerId, 'readerType' => ''];
+ }
+
+ $process = EctMetaProcess::where('process_id', $facility->process_id)
+ ->select('process_name')
+ ->first();
+
+ $readerType = (string)$process?->process_name;
+
+ return ['readerId' => $readerId, 'readerType' => $readerType];
+ }
+}
diff --git a/app/repository/UserRepository.php b/app/repository/UserRepository.php
new file mode 100644
index 0000000..925be7e
--- /dev/null
+++ b/app/repository/UserRepository.php
@@ -0,0 +1,119 @@
+model = new EctUser();
+ }
+
+ /**
+ * @param int $user_id
+ * @return User
+ * @throws IllegalUsageException
+ */
+ public function getUser(int $user_id): User
+ {
+ $result = EctUser::where('user_id', $user_id)->first();
+ if ($result === null) throw new IllegalUsageException();
+ return new User($result->user_id, $result->user_name, $result->role_id, $result->department_id, $result->user_rfid);
+ }
+
+ /**
+ * 返回用户的rfid
+ */
+ public function getRfid(User $user): string
+ {
+ return EctUser::where('user_id', $user->user_id)->first();
+ }
+
+ /**
+ * 根据用户名返回用户
+ * @param string $name
+ * @return array
+ * @throws IllegalUsageException
+ */
+ public function getUserByName(string $name): array
+ {
+ $list = [];
+ $ectUsers = EctUser::where('user_name', $name)->get();
+ if ($ectUsers === null) throw new IllegalUsageException();
+ foreach ($ectUsers as $ectUser) {
+ $list[] = new User($ectUser->user_id, $ectUser->user_name, $ectUser->role_id, $ectUser->department_id, $ectUser->user_rfid);
+ }
+ return $list;
+ }
+
+ /**
+ * @param int $id
+ * @return array
+ * @throws IllegalUsageException
+ */
+ public function getUserById(int $id): array
+ {
+ $ectUsers = EctUser::where('user_id', $id)->first();
+ if ($ectUsers === null) throw new IllegalUsageException();
+ $list = [];
+ foreach ($ectUsers as $ectUser) {
+ $list[] = new User($ectUser->user_id, $ectUser->user_name, $ectUser->role_id, $ectUser->department_id, $ectUser->user_rfid);
+ }
+ return $list;
+ }
+
+ /**
+ * @return array 用户列表
+ */
+ public function getAll(): array
+ {
+ $list = [];
+ $all = EctUser::all();
+ foreach ($all as $user) {
+ $list[] = new User($user->user_id, $user->user_name, $user->role_id, $user->department_id, $user->user_rfid);
+ }
+ return $list;
+ }
+
+ /**
+ * 根据 RFID 卡号查询操作员(人员卡识别用)
+ *
+ * @param string $rfid RFID 卡号(自动转大写匹配)
+ * @return EctUser|null 找到返回模型,找不到返回 null
+ * @throws ResultNotAsExpectedException
+ */
+ public function findByRfid(string $rfid): ?EctUser
+ {
+ $rfid = strtoupper($rfid);
+ $isAllDigit = ctype_digit($rfid);
+
+ $rfids = EctUser::where(function ($query) use ($rfid, $isAllDigit) {
+ $query->where('user_rfid', $rfid)
+ ->orWhere('rfid3', $rfid);
+ if ($isAllDigit) {
+ $rfidWithoutZero = ltrim($rfid, '0');
+ $rfidWithoutZero = $rfidWithoutZero === '' ? '0' : $rfidWithoutZero;
+ $query->orWhere('user_rfid', $rfidWithoutZero)
+ ->orWhere('rfid3', $rfidWithoutZero);
+ }
+ })->distinct()
+ ->get();
+ // 如果数量不对就抛出异常
+ if (count($rfids) > 1 || count($rfids) == 0) {
+ throw new ResultNotAsExpectedException("RFID Conflict, rfid {$rfid} count: " . count($rfids));
+ }
+ return $rfids[0];
+ }
+
+ public static function new(): BaseRepository
+ {
+ return new self();
+ }
+}
\ No newline at end of file
diff --git a/app/repository/bean/User.php b/app/repository/bean/User.php
new file mode 100644
index 0000000..57a5df7
--- /dev/null
+++ b/app/repository/bean/User.php
@@ -0,0 +1,50 @@
+ $this->user_id;
+ }
+ /**
+ * @var string user name
+ */
+ public string $name {
+ get => $this->name;
+ }
+ /**
+ * @var int role id
+ */
+ public int $roleId {
+ get => $this->role->value;
+ }
+
+ public UserRole $role{
+ get => $this->role;
+ }
+ /**
+ * @var int department id
+ */
+ public int $departmentId {
+ get => $this->departmentId;
+ }
+ /**
+ * @var string 人员卡编码
+ */
+ public string $rfidCode {
+ get => $this->rfidCode;
+ }
+
+ public function __construct(int $user_id, string $name, int $roleId, int $departmentId, string $rfid)
+ {
+ $this->user_id = $user_id;
+ $this->name = $name;
+ $this->role = UserRole::from($roleId);
+ $this->departmentId = $departmentId;
+ $this->rfidCode = $rfid;
+ }
+}
\ No newline at end of file
diff --git a/app/repository/bean/UserRole.php b/app/repository/bean/UserRole.php
new file mode 100644
index 0000000..db268bc
--- /dev/null
+++ b/app/repository/bean/UserRole.php
@@ -0,0 +1,21 @@
+ trim($row) !== '');
+ if (empty($validRows)) return '';
+
+ // 计算最长行的字符长度(核心:按实际字符数计算)
+ $maxContentLen = max(array_map('mb_strlen', $validRows));
+
+ // 格式化每行:左侧固定前缀,右侧补空格至最长长度
+ $formattedRows = array_map(function ($row) use ($maxContentLen) {
+ $currentLen = mb_strlen($row);
+ $padding = $maxContentLen - $currentLen; // 精准计算需要补充的空格数
+ return '| ' . $row . str_repeat(' ', max(0, $padding)) . ' |';
+ }, $validRows);
+
+ // 计算边框总宽度(格式化后最长行的长度)
+ $borderLen = max(array_map('strlen', $formattedRows));
+ $borderLine = str_repeat($borderChar, $borderLen);
+
+ // 空行(和内容行宽度一致)
+ $emptyLine = '| ' . str_repeat(' ', $maxContentLen) . ' |';
+
+ // 拼接文本框
+ return "\n{$borderLine}\n{$emptyLine}\n" . implode("\n", $formattedRows) . "\n{$emptyLine}\n{$borderLine}";
+ }
+
/**
* 静态信息级别日志
* @param string $message 日志消息
* @param array|Throwable $context 占位符参数或异常对象
* @param string $name 日志器名称
*/
- public static function InfoMsg(string $message, array|Throwable $context = [], string $name = '__default'): void
+ public static function info(string $message, array|Throwable $context = [], string $name = '__default'): void
{
self::new($name)->info($message, $context);
}
diff --git a/app/utils/ModelAutoGenerator.php b/app/utils/ModelAutoGenerator.php
new file mode 100644
index 0000000..7799d64
--- /dev/null
+++ b/app/utils/ModelAutoGenerator.php
@@ -0,0 +1,337 @@
+ bool, 'msg' => string]
+ */
+ public static function generate(string $tableName, string $modelName, bool $force = false): array
+ {
+ // 1. 定义模型文件路径(兼容webman的app_path)
+ $modelPath = app_path() . '/model/' . ucfirst($modelName) . '.php';
+ $modelClassName = ucfirst($modelName); // 确保类名首字母大写
+
+ // 2. 检查文件是否已存在,存在则返回不覆盖提示
+ if (file_exists($modelPath) && !$force) {
+ return [
+ 'status' => false,
+ 'msg' => "模型文件 {$modelClassName}.php 已存在,不执行覆盖操作"
+ ];
+ }
+
+ try {
+ // 3. 读取数据表基本信息(包含表注释)- 修复参数绑定问题
+ $tableComment = self::getTableComment($tableName);
+ // 4. 读取数据表结构(包含字段注释、类型等)
+ $tableStruct = self::getTableStruct($tableName);
+
+ if (empty($tableStruct)) {
+ return [
+ 'status' => false,
+ 'msg' => "数据表 {$tableName} 无字段信息,生成失败"
+ ];
+ }
+
+ // 5. 构建模型文件内容(传入表注释)
+ $modelContent = self::buildModelContent($tableName, $modelClassName, $tableStruct, $tableComment);
+
+ // 6. 确保model目录存在
+ if (!is_dir(dirname($modelPath))) {
+ mkdir(dirname($modelPath), 0755, true);
+ }
+
+ // 7. 写入模型文件
+ $writeResult = file_put_contents($modelPath, $modelContent);
+ if ($writeResult === false) {
+ throw new RuntimeException("模型文件写入失败,检查目录权限");
+ }
+
+ return [
+ 'status' => true,
+ 'msg' => "模型 {$modelClassName}.php 已成功生成至 app/model 目录"
+ ];
+
+ } catch (\Exception $e) {
+ return [
+ 'status' => false,
+ 'msg' => "生成失败:{$e->getMessage()}"
+ ];
+ }
+ }
+
+ /**
+ * 批量生成所有数据表的模型文件
+ *
+ * @param bool $force 是否强制覆盖已存在的模型文件
+ * @param array $excludeTables 排除的数据表(如:['migrations', 'logs'])
+ * @return array 批量生成结果 ['success' => 成功数量, 'fail' => 失败列表]
+ */
+ public static function generate_all(bool $force = false, array $excludeTables = []): array
+ {
+ $result = [
+ 'success' => 0,
+ 'fail' => []
+ ];
+
+ try {
+ // 1. 获取数据库中所有数据表
+ $tables = Db::select("SHOW TABLES");
+ $tableNameKey = 'Tables_in_' . config('database.connections.mysql.database');
+
+ foreach ($tables as $table) {
+ $tableName = $table->$tableNameKey;
+
+ // 跳过排除的表
+ if (in_array($tableName, $excludeTables)) {
+ continue;
+ }
+
+ // 跳过视图(以 v_ 开头的表,避免视图生成模型)
+ if (str_starts_with($tableName, 'v_')) {
+ continue;
+ }
+
+ // 2. 表名转模型名(下划线转驼峰,如 user_info → UserInfo)
+ $modelName = self::tableNameToModelName($tableName);
+
+ // 3. 调用单个生成方法
+ $generateResult = self::generate($tableName, $modelName, $force);
+ if ($generateResult['status']) {
+ $result['success']++;
+ } else {
+ $result['fail'][] = [
+ 'table' => $tableName,
+ 'model' => $modelName,
+ 'reason' => $generateResult['msg']
+ ];
+ }
+ }
+
+ return $result;
+
+ } catch (\Exception $e) {
+ $result['fail'][] = [
+ 'table' => 'all',
+ 'model' => 'all',
+ 'reason' => "批量生成异常:{$e->getMessage()}"
+ ];
+ return $result;
+ }
+ }
+
+ /**
+ * 获取数据表注释(修复参数绑定问题)
+ *
+ * @param string $tableName 数据表名
+ * @return string 表注释(无注释则返回空字符串)
+ */
+ private static function getTableComment(string $tableName): string
+ {
+ // 修复核心:不使用参数绑定,直接拼接表名(先过滤表名防止注入)
+ $safeTableName = preg_replace('/[^a-zA-Z0-9_]/', '', $tableName);
+ $sql = "SHOW TABLE STATUS LIKE '{$safeTableName}'";
+
+ try {
+ $tableInfo = Db::select($sql);
+ } catch (\Exception $e) {
+ // 读取表注释失败时返回空字符串,不影响模型生成
+ return '';
+ }
+
+ if (empty($tableInfo)) {
+ return '';
+ }
+
+ // 兼容不同数据库驱动的字段名(Comment/comment)
+ $comment = $tableInfo[0]->Comment ?? $tableInfo[0]->comment ?? '';
+ return trim($comment);
+ }
+
+ /**
+ * 获取数据表结构(修复关键字冲突+兼容字段注释读取)
+ *
+ * @param string $tableName 数据表名
+ * @return array 表结构数组
+ */
+ private static function getTableStruct(string $tableName): array
+ {
+ // 表名安全过滤
+ $safeTableName = preg_replace('/[^a-zA-Z0-9_]/', '', $tableName);
+ // 修复关键字冲突:给别名加反引号,避免与MySQL保留字冲突
+ $sql = "SELECT
+ COLUMN_NAME AS `Field`,
+ DATA_TYPE AS `TypeSimple`,
+ COLUMN_TYPE AS `Type`,
+ IS_NULLABLE AS `IsNull`,
+ COLUMN_KEY AS `ColumnKey`,
+ COLUMN_DEFAULT AS `ColumnDefault`,
+ COLUMN_COMMENT AS `Comment`
+ FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = ?
+ AND TABLE_NAME = ?
+ ORDER BY ORDINAL_POSITION";
+
+ // 使用参数绑定读取字段信息(INFORMATION_SCHEMA 支持参数绑定)
+ $tableStruct = Db::select($sql, [
+ config('database.connections.mysql.database'),
+ $safeTableName
+ ]);
+
+ // 格式化字段信息,确保注释字段统一
+ return array_map(function ($field) {
+ return (object)[
+ 'Field' => $field->Field ?? '',
+ 'Type' => $field->Type ?? $field->TypeSimple ?? '',
+ 'Key' => $field->ColumnKey ?? '', // 对应修改后的别名
+ // 优先读取 COMMENT,兼容大小写,确保去空格
+ 'Comment' => trim($field->Comment ?? $field->comment ?? ''),
+ 'Null' => $field->IsNull ?? '', // 对应修改后的别名
+ 'Default' => $field->ColumnDefault ?? '' // 对应修改后的别名
+ ];
+ }, $tableStruct);
+ }
+
+ /**
+ * 构建模型文件内容(确保字段注释正确展示)
+ *
+ * @param string $tableName 数据表名
+ * @param string $modelClassName 模型类名
+ * @param array $tableStruct 表结构信息
+ * @param string $tableComment 表注释
+ * @return string 模型文件内容
+ */
+ private static function buildModelContent(string $tableName, string $modelClassName, array $tableStruct, string $tableComment): string
+ {
+ // 提取主键
+ $primaryKey = 'id';
+ foreach ($tableStruct as $field) {
+ if ($field->Key === 'PRI') {
+ $primaryKey = $field->Field;
+ break;
+ }
+ }
+
+ // 构建字段注释(格式:字段名 数据库注释)
+ $fieldComments = [];
+ foreach ($tableStruct as $field) {
+ $fieldName = $field->Field; // 原始字段名(如 dt)
+ $dbComment = $field->Comment; // 数据库注释(如 基准时间)
+
+ // 拼接注释:字段名 + (有数据库注释则加)数据库注释
+ $fullComment = $fieldName;
+ if (!empty($dbComment)) {
+ $fullComment .= " {$dbComment}";
+ }
+
+ // 补充字段属性说明(非空、主键、默认值)
+ $extComment = [];
+ if ($field->Null === 'NO') {
+ $extComment[] = '非空';
+ }
+ if ($field->Key === 'PRI') {
+ $extComment[] = '主键';
+ }
+ if ($field->Default !== '') {
+ $extComment[] = "默认值:{$field->Default}";
+ }
+
+ // 如有属性说明,追加到注释末尾
+ if (!empty($extComment)) {
+ $fullComment .= '(' . implode(',', $extComment) . ')';
+ }
+
+ $fieldType = self::dbTypeToPhpType($field->Type);
+ $fieldComments[] = " * @property {$fieldType} \${$fieldName} {$fullComment}";
+ }
+ $fieldCommentsStr = implode("\n", $fieldComments);
+
+ // 构建表注释(无注释则使用默认描述)
+ $tableCommentStr = !empty($tableComment) ? $tableComment : "{$tableName} 数据表模型";
+
+ // 模型模板(适配webman的think-orm)
+ return << 'int',
+ 'float', 'double', 'decimal' => 'float',
+ 'date', 'time', 'datetime', 'timestamp' => 'string',
+ 'bool', 'boolean' => 'bool',
+ default => 'string'
+ };
+ }
+}
\ No newline at end of file
diff --git a/webman/app/view/index/view.html b/app/view/index/view.html
similarity index 100%
rename from webman/app/view/index/view.html
rename to app/view/index/view.html
diff --git a/webman/composer.json b/composer.json
similarity index 90%
rename from webman/composer.json
rename to composer.json
index f85b453..c848049 100644
--- a/webman/composer.json
+++ b/composer.json
@@ -24,7 +24,7 @@
"source": "https://github.com/walkor/webman"
},
"require": {
- "php": ">=8.1",
+ "php": ">=8.4",
"workerman/webman-framework": "^2.1",
"monolog/monolog": "^2.0",
"webman/console": "^2.2",
@@ -32,7 +32,8 @@
"webman/think-orm": "^2.1",
"illuminate/pagination": "^12.53",
"illuminate/events": "^12.53",
- "symfony/var-dumper": "^8.0"
+ "symfony/var-dumper": "^8.0",
+ "vlucas/phpdotenv": "^5.6"
},
"suggest": {
"ext-event": "For better performance. "
@@ -63,5 +64,8 @@
]
},
"minimum-stability": "dev",
- "prefer-stable": true
+ "prefer-stable": true,
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ }
}
diff --git a/webman/composer.lock b/composer.lock
similarity index 70%
rename from webman/composer.lock
rename to composer.lock
index d3e2351..783b309 100644
--- a/webman/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "43596e48af65fedae2108be6156f1ce1",
+ "content-hash": "ecd59dd718e1f410ae9c5a01fdd8c1be",
"packages": [
{
"name": "brick/math",
@@ -296,6 +296,68 @@
],
"time": "2025-12-03T09:33:47+00:00"
},
+ {
+ "name": "graham-campbell/result-type",
+ "version": "v1.1.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/GrahamCampbell/Result-Type.git",
+ "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b",
+ "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0",
+ "phpoption/phpoption": "^1.9.5"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "GrahamCampbell\\ResultType\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ }
+ ],
+ "description": "An Implementation Of The Result Type",
+ "keywords": [
+ "Graham Campbell",
+ "GrahamCampbell",
+ "Result Type",
+ "Result-Type",
+ "result"
+ ],
+ "support": {
+ "issues": "https://github.com/GrahamCampbell/Result-Type/issues",
+ "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-12-27T19:43:20+00:00"
+ },
{
"name": "guzzlehttp/guzzle",
"version": "7.10.0",
@@ -1886,6 +1948,81 @@
},
"time": "2018-02-13T20:26:39+00:00"
},
+ {
+ "name": "phpoption/phpoption",
+ "version": "1.9.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/schmittjoh/php-option.git",
+ "reference": "75365b91986c2405cf5e1e012c5595cd487a98be"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be",
+ "reference": "75365b91986c2405cf5e1e012c5595cd487a98be",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ },
+ "branch-alias": {
+ "dev-master": "1.9-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpOption\\": "src/PhpOption/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Johannes M. Schmitt",
+ "email": "schmittjoh@gmail.com",
+ "homepage": "https://github.com/schmittjoh"
+ },
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ }
+ ],
+ "description": "Option Type for PHP",
+ "keywords": [
+ "language",
+ "option",
+ "php",
+ "type"
+ ],
+ "support": {
+ "issues": "https://github.com/schmittjoh/php-option/issues",
+ "source": "https://github.com/schmittjoh/php-option/tree/1.9.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-12-27T19:41:33+00:00"
+ },
{
"name": "psr/clock",
"version": "1.0.0",
@@ -4565,6 +4702,90 @@
},
"time": "2025-06-11T05:51:40+00:00"
},
+ {
+ "name": "vlucas/phpdotenv",
+ "version": "v5.6.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/vlucas/phpdotenv.git",
+ "reference": "955e7815d677a3eaa7075231212f2110983adecc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc",
+ "reference": "955e7815d677a3eaa7075231212f2110983adecc",
+ "shasum": ""
+ },
+ "require": {
+ "ext-pcre": "*",
+ "graham-campbell/result-type": "^1.1.4",
+ "php": "^7.2.5 || ^8.0",
+ "phpoption/phpoption": "^1.9.5",
+ "symfony/polyfill-ctype": "^1.26",
+ "symfony/polyfill-mbstring": "^1.26",
+ "symfony/polyfill-php80": "^1.26"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.2",
+ "ext-filter": "*",
+ "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2"
+ },
+ "suggest": {
+ "ext-filter": "Required to use the boolean validator."
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ },
+ "branch-alias": {
+ "dev-master": "5.6-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Dotenv\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Vance Lucas",
+ "email": "vance@vancelucas.com",
+ "homepage": "https://github.com/vlucas"
+ }
+ ],
+ "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.",
+ "keywords": [
+ "dotenv",
+ "env",
+ "environment"
+ ],
+ "support": {
+ "issues": "https://github.com/vlucas/phpdotenv/issues",
+ "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-12-27T19:49:13+00:00"
+ },
{
"name": "voku/portable-ascii",
"version": "2.0.3",
@@ -4950,7 +5171,1803 @@
"time": "2026-01-09T03:26:15+00:00"
}
],
- "packages-dev": [],
+ "packages-dev": [
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.13.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-01T08:46:24+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
+ },
+ "time": "2025-12-06T11:56:16+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
+ },
+ "time": "2022-02-21T01:04:05+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "13.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "a8b58fde2f4fbc69a064e1f80ff917607cf7737c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/a8b58fde2f4fbc69a064e1f80ff917607cf7737c",
+ "reference": "a8b58fde2f4fbc69a064e1f80ff917607cf7737c",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^5.7.0",
+ "php": ">=8.4",
+ "phpunit/php-file-iterator": "^7.0",
+ "phpunit/php-text-template": "^6.0",
+ "sebastian/complexity": "^6.0",
+ "sebastian/environment": "^9.0",
+ "sebastian/lines-of-code": "^5.0",
+ "sebastian/version": "^7.0",
+ "theseer/tokenizer": "^2.0.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "suggest": {
+ "ext-pcov": "PHP extension that provides line coverage",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "13.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/13.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T06:05:15+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50",
+ "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:33:26+00:00"
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88",
+ "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^13.0"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "keywords": [
+ "process"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "security": "https://github.com/sebastianbergmann/php-invoker/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:34:47+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4",
+ "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:36:37+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "9.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6",
+ "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "security": "https://github.com/sebastianbergmann/php-timer/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:37:53+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "13.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "d57826e8921a534680c613924bfd921ded8047f4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d57826e8921a534680c613924bfd921ded8047f4",
+ "reference": "d57826e8921a534680c613924bfd921ded8047f4",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xml": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.13.4",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
+ "php": ">=8.4.1",
+ "phpunit/php-code-coverage": "^13.0.1",
+ "phpunit/php-file-iterator": "^7.0.0",
+ "phpunit/php-invoker": "^7.0.0",
+ "phpunit/php-text-template": "^6.0.0",
+ "phpunit/php-timer": "^9.0.0",
+ "sebastian/cli-parser": "^5.0.0",
+ "sebastian/comparator": "^8.0.0",
+ "sebastian/diff": "^8.0.0",
+ "sebastian/environment": "^9.0.0",
+ "sebastian/exporter": "^8.0.0",
+ "sebastian/global-state": "^9.0.0",
+ "sebastian/object-enumerator": "^8.0.0",
+ "sebastian/recursion-context": "^8.0.0",
+ "sebastian/type": "^7.0.0",
+ "sebastian/version": "^7.0.0",
+ "staabm/side-effects-detector": "^1.0.5"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "13.0-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Framework/Assert/Functions.php"
+ ],
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/13.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsors.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-18T12:40:03+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca",
+ "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:39:44+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "8.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "29b232ddc29c2b114c0358c69b3084e7c3da0d58"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/29b232ddc29c2b114c0358c69b3084e7c3da0d58",
+ "reference": "29b232ddc29c2b114c0358c69b3084e7c3da0d58",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "php": ">=8.4",
+ "sebastian/diff": "^8.0",
+ "sebastian/exporter": "^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "suggest": {
+ "ext-bcmath": "For comparing BcMath\\Number objects"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "security": "https://github.com/sebastianbergmann/comparator/security/policy",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/8.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:40:39+00:00"
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "c5651c795c98093480df79350cb050813fc7a2f3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3",
+ "reference": "c5651c795c98093480df79350cb050813fc7a2f3",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "security": "https://github.com/sebastianbergmann/complexity/security/policy",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:41:32+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "8.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "a2b6d09d7729ee87d605a439469f9dcc39be5ea3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a2b6d09d7729ee87d605a439469f9dcc39be5ea3",
+ "reference": "a2b6d09d7729ee87d605a439469f9dcc39be5ea3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0",
+ "symfony/process": "^7.2"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "security": "https://github.com/sebastianbergmann/diff/security/policy",
+ "source": "https://github.com/sebastianbergmann/diff/tree/8.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/diff",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:42:27+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "9.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "bb64d08145b021b67d5f253308a498b73ab0461e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/bb64d08145b021b67d5f253308a498b73ab0461e",
+ "reference": "bb64d08145b021b67d5f253308a498b73ab0461e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "https://github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "security": "https://github.com/sebastianbergmann/environment/security/policy",
+ "source": "https://github.com/sebastianbergmann/environment/tree/9.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:43:29+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "8.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "dc31f1f8e0186c8f0bb3e48fd4d51421d8905fea"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/dc31f1f8e0186c8f0bb3e48fd4d51421d8905fea",
+ "reference": "dc31f1f8e0186c8f0bb3e48fd4d51421d8905fea",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=8.4",
+ "sebastian/recursion-context": "^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/8.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:44:28+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "9.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e52e3dc22441e6218c710afe72c3042f8fc41ea7",
+ "reference": "e52e3dc22441e6218c710afe72c3042f8fc41ea7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "sebastian/object-reflector": "^6.0",
+ "sebastian/recursion-context": "^8.0"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "https://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "security": "https://github.com/sebastianbergmann/global-state/security/policy",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:45:13+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "5.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/4f21bb7768e1c997722ccc7efb1d6b5c11bfd471",
+ "reference": "4f21bb7768e1c997722ccc7efb1d6b5c11bfd471",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^5.0",
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:45:54+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "8.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5",
+ "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "sebastian/object-reflector": "^6.0",
+ "sebastian/recursion-context": "^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:46:36+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "6.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200",
+ "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "6.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "security": "https://github.com/sebastianbergmann/object-reflector/security/policy",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:47:13+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "8.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e",
+ "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "8.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:51:28+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "42412224607bd3931241bbd17f38e0f972f5a916"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/42412224607bd3931241bbd17f38e0f972f5a916",
+ "reference": "42412224607bd3931241bbd17f38e0f972f5a916",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^13.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "security": "https://github.com/sebastianbergmann/type/security/policy",
+ "source": "https://github.com/sebastianbergmann/type/tree/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/type",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:52:09+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "7.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b",
+ "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "7.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "security": "https://github.com/sebastianbergmann/version/security/policy",
+ "source": "https://github.com/sebastianbergmann/version/tree/7.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/version",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-02-06T04:52:52+00:00"
+ },
+ {
+ "name": "staabm/side-effects-detector",
+ "version": "1.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/staabm/side-effects-detector.git",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163",
+ "reference": "d8334211a140ce329c13726d4a715adbddd0a163",
+ "shasum": ""
+ },
+ "require": {
+ "ext-tokenizer": "*",
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan": "^1.12.6",
+ "phpunit/phpunit": "^9.6.21",
+ "symfony/var-dumper": "^5.4.43",
+ "tomasvotruba/type-coverage": "1.0.0",
+ "tomasvotruba/unused-public": "1.0.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "lib/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A static analysis tool to detect side effects in PHP code",
+ "keywords": [
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/staabm/side-effects-detector/issues",
+ "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/staabm",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-20T05:08:20+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "2.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^8.1"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/2.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2025-12-08T11:19:18+00:00"
+ }
+ ],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {},
diff --git a/webman/config/app.php b/config/app.php
similarity index 100%
rename from webman/config/app.php
rename to config/app.php
diff --git a/webman/config/autoload.php b/config/autoload.php
similarity index 100%
rename from webman/config/autoload.php
rename to config/autoload.php
diff --git a/webman/config/bootstrap.php b/config/bootstrap.php
similarity index 93%
rename from webman/config/bootstrap.php
rename to config/bootstrap.php
index ce7bfb2..5e7047a 100644
--- a/webman/config/bootstrap.php
+++ b/config/bootstrap.php
@@ -15,4 +15,5 @@
return [
support\bootstrap\Session::class,
Webman\ThinkOrm\ThinkOrm::class,
+ app\bootstrap\SqlDebug::class,
];
diff --git a/webman/config/container.php b/config/container.php
similarity index 100%
rename from webman/config/container.php
rename to config/container.php
diff --git a/webman/config/database.php b/config/database.php
similarity index 65%
rename from webman/config/database.php
rename to config/database.php
index e436e95..07c0fb5 100644
--- a/webman/config/database.php
+++ b/config/database.php
@@ -1,14 +1,17 @@
'mysql',
'connections' => [
'mysql' => [
'driver' => 'mysql',
- 'host' => '127.0.0.1',
- 'port' => '3306',
- 'database' => 'your_database',
- 'username' => 'your_username',
- 'password' => 'your_password',
+ 'host' => Config::getStringEnv('DB_HOST', 'localhost'),
+ 'port' => Config::getIntEnv('DB_PORT',3306),
+ 'database' => Config::getStringEnv('DB_NAME', 'opm_ectms'),
+ 'username' => Config::getStringEnv('DB_USER', 'root'),
+ 'password' => Config::getStringEnv('DB_PASSWORD'),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_general_ci',
'prefix' => '',
diff --git a/webman/config/dependence.php b/config/dependence.php
similarity index 100%
rename from webman/config/dependence.php
rename to config/dependence.php
diff --git a/webman/config/exception.php b/config/exception.php
similarity index 100%
rename from webman/config/exception.php
rename to config/exception.php
diff --git a/webman/config/log.php b/config/log.php
similarity index 60%
rename from webman/config/log.php
rename to config/log.php
index 6214651..b6fec0a 100644
--- a/webman/config/log.php
+++ b/config/log.php
@@ -1,9 +1,12 @@
[
@@ -13,7 +16,7 @@ return [
'class' => StreamHandler::class,
'constructor' => [
'php://stdout',
- Logger::DEBUG,
+ Config::getInstance()->logLevel,
],
'formatter' => [
'class' => Monolog\Formatter\LineFormatter::class,
@@ -44,8 +47,8 @@ return [
'class' => RotatingFileHandler::class,
'constructor' => [
runtime_path() . '/logs/webman.log',
- 7,
- Logger::DEBUG,
+ Config::getInstance()->logRotationTimeByDay,
+ Config::getInstance()->logLevel,
],
'formatter' => [
'class' => Monolog\Formatter\LineFormatter::class,
@@ -62,7 +65,7 @@ return [
'constructor' => [
new RotatingFileHandler(
runtime_path() . '/logs/error.log',
- 7,
+ Config::getInstance()->errorLogRotationTimeByDay,
Logger::DEBUG
),
Logger::ERROR,
@@ -82,7 +85,7 @@ return [
'processors' => [
// 1. 注入进程ID(生成线程名)
new ProcessIdProcessor(),
- // 2. 手动解析调用栈,获取实际业务代码位置
+ // 2. 手动解析调用栈,获取实际业务代码位置 + 日志过滤
function ($record) {
$message = $record['message'];
// 线程名
@@ -101,6 +104,7 @@ return [
if (isset($step['file'])
&& str_contains($step['file'], 'support') === false
&& str_contains($step['file'], 'monolog') === false
+ && str_contains($step['file'], 'Logger') === false
) {
// 获取文件路径(简化为项目内相对路径)
$file = $step['file'];
@@ -114,7 +118,7 @@ return [
// 获取方法名
$function = $step['function'];
// 获取行
- $line = $step['line']??'0';
+ $line = $step['line'] ?? '0';
break; // 找到第一个业务代码层就停止
}
}
@@ -123,23 +127,41 @@ return [
$record['L'] = $line;
$record['P'] = $record['extra']['process_id'];
+ // 解析 标签
$pattern = '//si';
if (preg_match_all($pattern, $message, $matches) && !empty($matches[1])) {
$record['logger'] = $matches[1][0];
$record['message'] = preg_replace($pattern, '', $message);
- }else{
+ if ($matches[1][0] == '__default') $record['logger'] = $file;
+ } else {
$record['logger'] = $file;
$record['message'] = $message;
}
+ // 解析 SQL_LOG 相关标签
+ $tagPatterns = [
+ 'logger' => '/(.*?)<\/SQL_LOG_F>/s', // 文件路径
+ 'L' => '/(.*?)<\/SQL_LOG_L>/s', // 行号
+ 'M' => '/(.*?)<\/SQL_LOG_M>/s' // 方法名
+ ];
+ $tempMessage = $record['message']; // 基于处理后的message继续解析
+ foreach ($tagPatterns as $key => $pattern) {
+ if (preg_match($pattern, $tempMessage, $matches)) {
+ $record[$key] = $matches[1];
+ $tempMessage = preg_replace($pattern, '', $tempMessage); // 移除当前标签
+ $record['message'] = trim($tempMessage);
+ }
+ }
+
+ // 日志级别颜色映射
$levelColorMap = [
Logger::EMERGENCY => "\033[41m\033[37m", // 最高级别:红色底色+白色字体
- Logger::ALERT => "\033[41m\033[37m", // 警报:红色底色+白色字体
- Logger::CRITICAL => "\033[41m\033[37m", // 严重:红色底色+白色字体
- Logger::ERROR => "\033[31m", // 错误:红色字体(无底色)
- Logger::WARNING => "\033[33m", // 警告:深黄色字体
- Logger::INFO => "\033[32m", // 信息:绿色字体
- Logger::DEBUG => "\033[34m", // 调试:蓝色字体
+ Logger::ALERT => "\033[41m\033[37m", // 警报:红色底色+白色字体
+ Logger::CRITICAL => "\033[41m\033[37m", // 严重:红色底色+白色字体
+ Logger::ERROR => "\033[31m", // 错误:红色字体(无底色)
+ Logger::WARNING => "\033[33m", // 警告:深黄色字体
+ Logger::INFO => "\033[32m", // 信息:绿色字体
+ Logger::DEBUG => "\033[34m", // 调试:蓝色字体
];
$record['start'] = "\033[0m";
$record['end'] = "\033[0m";
@@ -151,9 +173,45 @@ return [
}
}
- $record['Level'] = str_pad(strtoupper($record['level_name']), 7);
+ $record['Level'] = str_pad(strtoupper($record['level_name']), 5);
+
+ $logFilter = Config::getInstance()->logFilter;
+
+ $matchWildcard = function (string $pattern, string $value): bool {
+ // 空规则特殊处理:如:debug 拆分后类规则为空,代表匹配所有类
+ if ($pattern === '') {
+ return true;
+ }
+ // 将通配符*转换为正则的.*,并转义其他特殊字符
+ $regexPattern = preg_quote($pattern, '/');
+ $regexPattern = str_replace('\*', '.*', $regexPattern);
+ // 正则全程匹配
+ return preg_match('/^' . $regexPattern . '$/', $value) === 1;
+ };
+ if (!empty($logFilter)) {
+ $className = $record['logger'] ?? '';
+ $methodName = $record['M'] ?? '';
+
+ // 遍历过滤规则,匹配则返回null(过滤日志)
+ foreach ($logFilter as $filter) {
+ // 拆分规则为类规则和方法规则(最多拆2部分,避免方法名含:)
+ [$filterClassRule, $filterMethodRule] = array_pad(explode(':', $filter, 2), 2, '*');
+
+ // 匹配类名规则(支持通配符*)
+ $isClassMatch = $matchWildcard($filterClassRule, $className);
+ // 匹配方法名规则(支持通配符*)
+ $isMethodMatch = $matchWildcard($filterMethodRule, $methodName);
+
+ // 类和方法都匹配时,过滤该日志
+ if ($isClassMatch && $isMethodMatch) {
+ return null;
+ }
+ }
+ }
+
return $record;
}
- ]
+ ],
+
],
];
\ No newline at end of file
diff --git a/webman/config/middleware.php b/config/middleware.php
similarity index 100%
rename from webman/config/middleware.php
rename to config/middleware.php
diff --git a/webman/config/plugin/webman/console/app.php b/config/plugin/webman/console/app.php
similarity index 100%
rename from webman/config/plugin/webman/console/app.php
rename to config/plugin/webman/console/app.php
diff --git a/webman/config/process.php b/config/process.php
similarity index 95%
rename from webman/config/process.php
rename to config/process.php
index 033ce2d..c536637 100644
--- a/webman/config/process.php
+++ b/config/process.php
@@ -12,6 +12,7 @@
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
+use app\config\Config;
use support\Log;
use support\Request;
use app\process\Http;
@@ -61,6 +62,6 @@ return [
],
'TcpServer' => [
'handler' => app\process\TcpServer::class,
- 'listen' => 'tcp://0.0.0.0:50000'
+ 'listen' => 'tcp://0.0.0.0:'. Config::getStringEnv("TCP_SERVER_PORT","50000")
],
];
diff --git a/webman/config/route.php b/config/route.php
similarity index 100%
rename from webman/config/route.php
rename to config/route.php
diff --git a/webman/config/server.php b/config/server.php
similarity index 100%
rename from webman/config/server.php
rename to config/server.php
diff --git a/webman/config/session.php b/config/session.php
similarity index 100%
rename from webman/config/session.php
rename to config/session.php
diff --git a/webman/config/static.php b/config/static.php
similarity index 100%
rename from webman/config/static.php
rename to config/static.php
diff --git a/webman/config/translation.php b/config/translation.php
similarity index 100%
rename from webman/config/translation.php
rename to config/translation.php
diff --git a/webman/config/view.php b/config/view.php
similarity index 100%
rename from webman/config/view.php
rename to config/view.php
diff --git a/webman/docker-compose.yml b/docker-compose.yml
similarity index 100%
rename from webman/docker-compose.yml
rename to docker-compose.yml
diff --git a/phpunit-ide.php b/phpunit-ide.php
new file mode 100644
index 0000000..d6ac7fa
--- /dev/null
+++ b/phpunit-ide.php
@@ -0,0 +1,37 @@
+
+
+
+
+ ./tests
+
+
+
+
+ ./app
+
+
+
\ No newline at end of file
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..7e47604
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,25 @@
+
+
+
+
+ tests/flow
+
+
+
+
+ app/flow
+
+
+
+
+
+
+
+
+
diff --git a/webman/public/favicon.ico b/public/favicon.ico
similarity index 100%
rename from webman/public/favicon.ico
rename to public/favicon.ico
diff --git a/webman/start.php b/start.php
similarity index 100%
rename from webman/start.php
rename to start.php
diff --git a/webman/support/Request.php b/support/Request.php
similarity index 100%
rename from webman/support/Request.php
rename to support/Request.php
diff --git a/webman/support/Response.php b/support/Response.php
similarity index 100%
rename from webman/support/Response.php
rename to support/Response.php
diff --git a/webman/support/Setup.php b/support/Setup.php
similarity index 100%
rename from webman/support/Setup.php
rename to support/Setup.php
diff --git a/webman/support/bootstrap.php b/support/bootstrap.php
similarity index 99%
rename from webman/support/bootstrap.php
rename to support/bootstrap.php
index d913def..78845f8 100644
--- a/webman/support/bootstrap.php
+++ b/support/bootstrap.php
@@ -27,7 +27,6 @@ $worker = $worker ?? null;
if (empty(Worker::$eventLoopClass)) {
Worker::$eventLoopClass = Select::class;
}
-
set_error_handler(function ($level, $message, $file = '', $line = 0) {
if (error_reporting() & $level) {
throw new ErrorException($message, 0, $level, $file, $line);
diff --git a/tests/db/DBC.php b/tests/db/DBC.php
new file mode 100644
index 0000000..566162a
--- /dev/null
+++ b/tests/db/DBC.php
@@ -0,0 +1,51 @@
+getConnection();
+ $this->assertNotNull($db);
+ }
+
+ /**
+ * 测试生成所有表
+ */
+// public function testGenTables()
+// {
+// $result = ModelAutoGenerator::generate_all(true);
+//
+// // 2. 断言返回结果结构正确
+// $this->assertArrayHasKey('success', $result, '批量生成结果缺少 success 字段');
+// $this->assertArrayHasKey('fail', $result, '批量生成结果缺少 fail 字段');
+// // 3. 断言 success 是整数类型
+// $this->assertIsInt($result['success'], 'success 字段不是整数');
+// // 4. 断言 fail 是数组类型
+// $this->assertIsArray($result['fail'], 'fail 字段不是数组');
+// // 5. 断言 fail 数组是空的
+// if (!empty($result['fail'])){
+// foreach ($result['fail'] as $key => $value){
+// Logger::error($value['table']."表生成失败:".$value['reason']);
+// }
+// }
+// $this->assertEmpty($result['fail'], 'fail 数组不是空的,说明有表生成失败');
+// }
+
+ public function testModel()
+ {
+ $users = EctUser::all();
+ $this->assertNotEmpty($users, '用户列表不能为空');
+ }
+}
\ No newline at end of file
diff --git a/tests/flow/BatchConsistencyTest.php b/tests/flow/BatchConsistencyTest.php
new file mode 100644
index 0000000..4e13af3
--- /dev/null
+++ b/tests/flow/BatchConsistencyTest.php
@@ -0,0 +1,330 @@
+createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'endoscopeId' => '',
+ 'morningWashed' => true,
+ ]);
+ $result1 = $engine->execute($context1);
+ $this->assertSuccess($result1);
+ $batchNo = $result1->batchNo;
+ $this->assertNotEmpty($batchNo);
+
+ // 2. 漂洗 - 批次号应该一致
+ $context2 = $this->createContext([
+ 'readerType' => '漂洗',
+ 'currentStep' => '清洗',
+ 'endoscopeId' => '',
+ 'batchNo' => $batchNo,
+ 'morningWashed' => true,
+ ]);
+ $this->setStepTime($context2, '清洗', 400);
+ $result2 = $engine->execute($context2);
+ $this->assertSuccess($result2);
+ $this->assertEquals($batchNo, $result2->batchNo);
+
+ // 3. 消毒 - 批次号应该一致
+ $context3 = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '漂洗',
+ 'endoscopeId' => '',
+ 'batchNo' => $batchNo,
+ 'morningWashed' => true,
+ ]);
+ $this->setStepTime($context3, '漂洗', 200);
+ $result3 = $engine->execute($context3);
+ $this->assertSuccess($result3);
+ $this->assertEquals($batchNo, $result3->batchNo);
+
+ // 4. 终末漂洗 - 批次号应该一致
+ $context4 = $this->createContext([
+ 'readerType' => '终末漂洗',
+ 'currentStep' => '消毒',
+ 'endoscopeId' => '',
+ 'batchNo' => $batchNo,
+ 'morningWashed' => true,
+ ]);
+ $this->setStepTime($context4, '消毒', 400);
+ $result4 = $engine->execute($context4);
+ $this->assertSuccess($result4);
+ $this->assertEquals($batchNo, $result4->batchNo);
+
+ // 5. 干燥 - 批次号应该一致
+ $context5 = $this->createContext([
+ 'readerType' => '干燥',
+ 'currentStep' => '终末漂洗',
+ 'endoscopeId' => '',
+ 'batchNo' => $batchNo,
+ 'morningWashed' => true,
+ ]);
+ $this->setStepTime($context5, '终末漂洗', 200);
+ $result5 = $engine->execute($context5);
+ $this->assertSuccess($result5);
+ $this->assertEquals($batchNo, $result5->batchNo);
+
+ // 6. 结束 - 批次号应该一致
+ $context6 = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '干燥',
+ 'endoscopeId' => '',
+ 'batchNo' => $batchNo,
+ 'morningWashed' => true,
+ ]);
+ $this->setStepTime($context6, '干燥', 300);
+ $result6 = $engine->execute($context6);
+ $this->assertSuccess($result6);
+ $this->assertEquals($batchNo, $result6->batchNo);
+ }
+
+ /**
+ * 测试多内镜批次号唯一性
+ */
+ public function testMultiEndoscopeBatchUniqueness(): void
+ {
+ $engine = ProcessEngine::createStandard();
+
+ $batchNos = [];
+
+ // 模拟5个不同内镜同时开始清洗
+ for ($i = 1; $i <= 5; $i++) {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'endoscopeId' => '',
+ 'morningWashed' => true,
+ ]);
+ $result = $engine->execute($context);
+ $this->assertSuccess($result);
+ $batchNos[] = $result->batchNo;
+ }
+
+ // 所有批次号应该唯一
+ $uniqueBatchNos = array_unique($batchNos);
+ $this->assertCount(5, $uniqueBatchNos);
+ }
+
+ /**
+ * 测试批次号格式正确性
+ */
+ public function testBatchNoFormat(): void
+ {
+ $context = $this->createContext([
+ 'endoscopeId' => '123',
+ ]);
+
+ $batchNo = $context->generateBatchNo();
+
+ // 验证格式:YYYYMMDDHHMMSS + 内镜ID(6位) + 随机数(4位)
+ $this->assertEquals(24, strlen($batchNo));
+ $this->assertMatchesRegularExpression('/^\d{14}\d{6}\d{4}$/', $batchNo);
+
+ // 验证日期部分
+ $datePart = substr($batchNo, 0, 8);
+ $this->assertEquals(date('Ymd'), $datePart);
+
+ // 验证内镜ID部分
+ $endoscopePart = substr($batchNo, 14, 6);
+ $this->assertEquals('000123', $endoscopePart);
+ }
+
+ /**
+ * 测试批次号不随时间变化(同一流程内)
+ */
+ public function testBatchNoStability(): void
+ {
+ $engine = ProcessEngine::createStandard();
+
+ $context1 = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'endoscopeId' => '',
+ 'morningWashed' => true,
+ ]);
+ $result1 = $engine->execute($context1);
+ $batchNo1 = $result1->batchNo;
+
+ // 模拟时间流逝(实际测试中无法真正等待,所以直接验证)
+ $context2 = $this->createContext([
+ 'readerType' => '漂洗',
+ 'currentStep' => '清洗',
+ 'endoscopeId' => '',
+ 'batchNo' => $batchNo1,
+ 'morningWashed' => true,
+ ]);
+ $this->setStepTime($context2, '清洗', 400);
+ $result2 = $engine->execute($context2);
+
+ // 批次号应该保持不变
+ $this->assertEquals($batchNo1, $result2->batchNo);
+ }
+
+ /**
+ * 测试新流程生成新批次号
+ */
+ public function testNewProcessGeneratesNewBatchNo(): void
+ {
+ $engine = ProcessEngine::createStandard();
+
+ // 第一个流程
+ $context1 = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'endoscopeId' => '',
+ 'morningWashed' => true,
+ ]);
+ $result1 = $engine->execute($context1);
+ $batchNo1 = $result1->batchNo;
+
+ // 完成第一个流程
+ $contextEnd = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '清洗',
+ 'endoscopeId' => '',
+ 'batchNo' => $batchNo1,
+ 'morningWashed' => true,
+ ]);
+ $engine->execute($contextEnd);
+
+ // 第二个流程(新流程)
+ $context2 = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '结束', // 上一步已结束
+ 'endoscopeId' => '',
+ 'morningWashed' => true,
+ ]);
+ $result2 = $engine->execute($context2);
+ $batchNo2 = $result2->batchNo;
+
+ // 新流程应该有新的批次号
+ $this->assertNotEquals($batchNo1, $batchNo2);
+ }
+
+ /**
+ * 测试分布式场景下的批次号一致性(模拟)
+ */
+ public function testDistributedBatchConsistency(): void
+ {
+ // 模拟机器A(清洗)
+ $engineA = ProcessEngine::createStandard();
+ $contextA = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'endoscopeId' => '',
+ 'morningWashed' => true,
+ ]);
+ $resultA = $engineA->execute($contextA);
+ $batchNo = $resultA->batchNo;
+
+ // 模拟机器B(消毒)- 从数据库获取批次号
+ $engineB = ProcessEngine::createStandard();
+ $contextB = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '漂洗',
+ 'endoscopeId' => '',
+ 'batchNo' => $batchNo, // 从数据库获取的批次号
+ 'morningWashed' => true,
+ ]);
+ $resultB = $engineB->execute($contextB);
+
+ // 批次号应该一致
+ $this->assertEquals($batchNo, $resultB->batchNo);
+ }
+
+ /**
+ * 测试批次号在错误情况下的保持
+ */
+ public function testBatchNoPreservedOnError(): void
+ {
+ $engine = ProcessEngine::createStandard();
+
+ // 开始清洗
+ $context1 = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'endoscopeId' => '',
+ 'morningWashed' => true,
+ ]);
+ $result1 = $engine->execute($context1);
+ $batchNo = $result1->batchNo;
+
+ // 完成漂洗
+ $contextRinse = $this->createContext([
+ 'readerType' => '漂洗',
+ 'currentStep' => '清洗',
+ 'endoscopeId' => '',
+ 'batchNo' => $batchNo,
+ 'morningWashed' => true,
+ ]);
+ $this->setStepTime($contextRinse, '清洗', 400); // 清洗时间足够
+ $resultRinse = $engine->execute($contextRinse);
+ $this->assertSuccess($resultRinse);
+
+ // 尝试再次漂洗(重复刷漂洗读卡器,时间验证会失败)
+ $context2 = $this->createContext([
+ 'readerType' => '漂洗',
+ 'currentStep' => '漂洗', // 当前已经是漂洗
+ 'endoscopeId' => '',
+ 'batchNo' => $batchNo,
+ 'morningWashed' => true,
+ ]);
+ // 设置漂洗步骤的上次时间为刚刚(时间不足)
+ $context2->setStepLastTime('漂洗', date('Y-m-d H:i:s', time() - 30));
+ $result2 = $engine->execute($context2);
+
+ // 执行失败(重复步骤或时间不足),但批次号应该保持不变
+ $this->assertEquals($batchNo, $result2->batchNo);
+ }
+
+ /**
+ * 测试不同流程类型的批次号
+ */
+ public function testBatchNoWithDifferentProcessTypes(): void
+ {
+ $engine = ProcessEngine::createStandard();
+
+ // 手工洗流程
+ $context1 = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'endoscopeId' => '',
+ 'morningWashed' => true,
+ ]);
+ $result1 = $engine->execute($context1);
+ $batchNo1 = $result1->batchNo;
+ $this->assertEquals('手工洗', $result1->processType);
+
+ // 机洗流程
+ $engineMachine = ProcessEngine::createMachineWash();
+ $context2 = $this->createContext([
+ 'readerType' => '机洗', // 使用机洗读卡器
+ 'currentStep' => '',
+ 'endoscopeId' => '',
+ 'morningWashed' => true,
+ ]);
+ $result2 = $engineMachine->execute($context2);
+ $batchNo2 = $result2->batchNo;
+ $this->assertEquals('机洗', $result2->processType);
+
+ // 不同流程的批次号应该不同
+ $this->assertNotEquals($batchNo1, $batchNo2);
+ }
+}
diff --git a/tests/flow/EdgeCaseTest.php b/tests/flow/EdgeCaseTest.php
new file mode 100644
index 0000000..84f4c50
--- /dev/null
+++ b/tests/flow/EdgeCaseTest.php
@@ -0,0 +1,314 @@
+assertEquals('', $context->endoscopeId);
+ $this->assertEquals('', $context->currentStep);
+ $this->assertTrue($context->success);
+ }
+
+ /**
+ * 测试超长内镜名称
+ */
+ public function testLongEndoscopeName(): void
+ {
+ $longName = str_repeat('北院电子胃镜', 10);
+ $context = $this->createContext([
+ 'endoscopeName' => $longName,
+ ]);
+
+ $context->setVoice('测试');
+ $fullVoice = $context->getFullVoice();
+
+ // 当前实现返回的是语音消息本身
+ $this->assertEquals('测试', $fullVoice);
+ }
+
+ /**
+ * 测试特殊字符内镜名称
+ */
+ public function testSpecialCharactersInName(): void
+ {
+ $specialName = '北院<电子>胃镜&001"\'';
+ $context = $this->createContext([
+ 'endoscopeName' => $specialName,
+ ]);
+
+ $context->setVoice('测试');
+ $fullVoice = $context->getFullVoice();
+
+ // 当前实现返回的是语音消息本身
+ $this->assertEquals('测试', $fullVoice);
+ }
+
+ /**
+ * 测试批次号生成(边界值)
+ */
+ public function testBatchNoGenerationEdgeCases(): void
+ {
+ // 内镜ID为0
+ $context1 = $this->createContext(['endoscopeId' => '0']);
+ $batchNo1 = $context1->generateBatchNo();
+ $this->assertEquals(24, strlen($batchNo1));
+ $this->assertStringContainsString('000000', $batchNo1);
+
+ // 内镜ID超长
+ $context2 = $this->createContext(['endoscopeId' => '123456789012345']);
+ $batchNo2 = $context2->generateBatchNo();
+ $this->assertEquals(24, strlen($batchNo2));
+
+ // 内镜ID为空
+ $context3 = $this->createContext(['endoscopeId' => '']);
+ $batchNo3 = $context3->generateBatchNo();
+ $this->assertEquals(24, strlen($batchNo3));
+ }
+
+ /**
+ * 测试时间边界值
+ */
+ public function testTimeBoundaryValues(): void
+ {
+ $context = $this->createContext();
+
+ // 时间为0
+ $this->assertEquals(0, $context->getStepDuration('不存在的步骤'));
+
+ // 设置时间为未来(异常情况)
+ $futureTime = date('Y-m-d H:i:s', time() + 3600);
+ $context->setStepLastTime('测试', $futureTime);
+ $this->assertEquals($futureTime, $context->getStepLastTime('测试'));
+ }
+
+ /**
+ * 测试空责任链
+ */
+ public function testEmptyChain(): void
+ {
+ $config = ProcessConfig::fromArray([
+ 'steps' => [],
+ ]);
+ $engine = new ProcessEngine($config);
+
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ ]);
+
+ $result = $engine->execute($context);
+
+ // 空链应该返回原始上下文
+ $this->assertEquals('', $result->currentStep);
+ }
+
+ /**
+ * 测试所有节点禁用
+ */
+ public function testAllNodesDisabled(): void
+ {
+ $config = ProcessConfig::createStandard();
+ $config->setNodeEnabled('晨洗', false);
+ $config->setNodeEnabled('清洗', false);
+ $config->setNodeEnabled('漂洗', false);
+ $config->setNodeEnabled('消毒', false);
+ $config->setNodeEnabled('终末漂洗', false);
+ $config->setNodeEnabled('干燥', false);
+ $config->setNodeEnabled('结束', false);
+
+ $engine = new ProcessEngine($config);
+
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'morningWashed' => true,
+ ]);
+
+ $result = $engine->execute($context);
+
+ // 没有节点能处理,保持原样
+ $this->assertEquals('', $result->currentStep);
+ }
+
+ /**
+ * 测试重复设置步骤时间
+ */
+ public function testRepeatedStepTimeSetting(): void
+ {
+ $context = $this->createContext();
+
+ $time1 = date('Y-m-d H:i:s', time() - 100);
+ $time2 = date('Y-m-d H:i:s', time() - 200);
+
+ $context->setStepLastTime('清洗', $time1);
+ $this->assertEquals($time1, $context->getStepLastTime('清洗'));
+
+ $context->setStepLastTime('清洗', $time2);
+ $this->assertEquals($time2, $context->getStepLastTime('清洗'));
+ }
+
+ /**
+ * 测试链式调用边界
+ */
+ public function testChainingEdgeCases(): void
+ {
+ $context = $this->createContext();
+
+ // 多次链式调用
+ $result = $context
+ ->setVoice('语音1')
+ ->setVoice('语音2')
+ ->setVoice('语音3');
+
+ $this->assertSame($context, $result);
+ $this->assertEquals('语音3', $context->voiceMessage);
+ }
+
+ /**
+ * 测试错误状态覆盖
+ */
+ public function testErrorStateOverwrite(): void
+ {
+ $context = $this->createContext();
+
+ $context->setError('错误1');
+ $this->assertFalse($context->success);
+ $this->assertEquals('错误1', $context->errorMessage);
+
+ // 再次设置错误
+ $context->setError('错误2');
+ $this->assertFalse($context->success);
+ $this->assertEquals('错误2', $context->errorMessage);
+ }
+
+ /**
+ * 测试从错误恢复
+ */
+ public function testRecoveryFromError(): void
+ {
+ $context = $this->createContext();
+
+ $context->setError('测试错误');
+ $this->assertFalse($context->success);
+
+ // 手动重置成功状态(实际业务中可能不需要)
+ $context->success = true;
+ $context->errorMessage = '';
+
+ $this->assertTrue($context->success);
+ }
+
+ /**
+ * 测试无效的配置项
+ */
+ public function testInvalidConfigItems(): void
+ {
+ $config = new ProcessConfig();
+
+ // 添加不存在的节点类
+ $config->addStep('无效步骤', 'NonExistentNode');
+ $step = $config->getStep('无效步骤');
+
+ $this->assertNotNull($step);
+ $this->assertEquals('NonExistentNode', $step['class']);
+ }
+
+ /**
+ * 测试并发批次号生成(模拟)
+ */
+ public function testConcurrentBatchNoGeneration(): void
+ {
+ $batchNos = [];
+ for ($i = 0; $i < 50; $i++) {
+ // 每个批次号使用不同的上下文(模拟不同内镜)
+ $context = $this->createContext(['endoscopeId' => (string)$i]);
+ $batchNo = $context->generateBatchNo();
+ $batchNos[] = $batchNo;
+ }
+
+ // 所有批次号应该唯一(不同内镜ID保证唯一性)
+ $uniqueBatchNos = array_unique($batchNos);
+ $this->assertCount(50, $uniqueBatchNos, '批次号应该唯一');
+ }
+
+ /**
+ * 测试晨洗策略边界 - 凌晨时间
+ */
+ public function testMorningWashBoundaryTime(): void
+ {
+ $strategy = new \app\flow\strategies\MorningWashStrategy([
+ 'mode' => 'daily_first',
+ 'morning_start_time' => '06:00:00',
+ ]);
+ $node = new \app\flow\nodes\WashNode();
+
+ // 凌晨5点(应该需要晨洗)
+ $context1 = $this->createContext([
+ 'todayWashRecords' => 0,
+ ]);
+ $result1 = $strategy->execute($context1, $node);
+ // daily_first 模式下,第一条记录需要晨洗
+ $this->assertTrue($result1->needMorningWash);
+ }
+
+ /**
+ * 测试存储时间模式边界 - 刚好4小时
+ */
+ public function testStorageTimeBoundary(): void
+ {
+ $strategy = new \app\flow\strategies\MorningWashStrategy([
+ 'mode' => 'storage_time',
+ 'storage_threshold' => 4,
+ ]);
+ $node = new \app\flow\nodes\WashNode();
+
+ // 刚好4小时前
+ $context = $this->createContext([
+ 'lastActionType' => '存储',
+ 'lastProcessName' => '内镜放入',
+ 'storageInTime' => date('Y-m-d H:i:s', time() - 4 * 3600),
+ ]);
+
+ $result = $strategy->execute($context, $node);
+
+ // 刚好4小时,应该不需要晨洗(超过才需要)
+ $this->assertFalse($result->needMorningWash);
+ }
+
+ /**
+ * 测试存储时间模式 - 超过4小时
+ */
+ public function testStorageTimeOverThreshold(): void
+ {
+ $strategy = new \app\flow\strategies\MorningWashStrategy([
+ 'mode' => 'storage_time',
+ 'storage_threshold' => 4,
+ ]);
+ $node = new \app\flow\nodes\WashNode();
+
+ // 4小时1秒前
+ $context = $this->createContext([
+ 'lastActionType' => '存储',
+ 'lastProcessName' => '内镜放入',
+ 'storageInTime' => date('Y-m-d H:i:s', time() - 4 * 3600 - 1),
+ ]);
+
+ $result = $strategy->execute($context, $node);
+
+ $this->assertTrue($result->needMorningWash);
+ }
+}
diff --git a/tests/flow/FlowProcessorTest.php b/tests/flow/FlowProcessorTest.php
new file mode 100644
index 0000000..9d5ff08
--- /dev/null
+++ b/tests/flow/FlowProcessorTest.php
@@ -0,0 +1,240 @@
+savedContexts[] = [
+ 'operation' => $context->dbOperation,
+ 'step' => $context->currentStep,
+ 'batchNo' => $context->batchNo,
+ 'processType' => $context->processType,
+ ];
+ }
+
+ protected function sendVoice(ProcessContext $context): void
+ {
+ $this->voiceSent[] = $context->getFullVoice();
+ }
+
+ protected function sendWebSocketNotification(ProcessContext $context): void
+ {
+ $this->wsSent[] = $context->currentStep;
+ }
+ };
+ }
+
+ /**
+ * 测试成功流程触发 saveToDatabase
+ */
+ public function testSuccessfulFlowSavesToDatabase(): void
+ {
+ $processor = $this->createTestableProcessor();
+
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'endoscopeId' => '1',
+ ]);
+
+ // 直接调用 handleResult(绕过 fromPacketContext)
+ $context->needDatabaseOperation = true;
+ $context->dbOperation = DbOperationType::INSERT;
+ $context->currentStep = '清洗';
+ $context->processType = '手工洗';
+ $context->batchNo = 'BATCH001';
+
+ $this->invokeHandleResult($processor, $context);
+
+ $this->assertNotEmpty($processor->savedContexts);
+ $this->assertEquals(DbOperationType::INSERT, $processor->savedContexts[0]['operation']);
+ $this->assertEquals('清洗', $processor->savedContexts[0]['step']);
+ }
+
+ /**
+ * 测试成功流程触发 sendVoice
+ */
+ public function testSuccessfulFlowSendsVoice(): void
+ {
+ $processor = $this->createTestableProcessor();
+
+ $context = $this->createContext([
+ 'endoscopeName' => '胃镜01',
+ ]);
+ $context->setVoice('清洗完成');
+ $context->needDatabaseOperation = false;
+
+ $this->invokeHandleResult($processor, $context);
+
+ $this->assertNotEmpty($processor->voiceSent);
+ $this->assertStringContainsString('清洗完成', $processor->voiceSent[0]);
+ }
+
+ /**
+ * 测试失败流程不触发 saveToDatabase
+ */
+ public function testFailedFlowDoesNotSaveToDatabase(): void
+ {
+ $processor = $this->createTestableProcessor();
+
+ $context = $this->createContext();
+ $context->setError('刷错,清洗剩余120秒');
+ $context->needDatabaseOperation = true;
+
+ $this->invokeHandleResult($processor, $context);
+
+ $this->assertEmpty($processor->savedContexts);
+ }
+
+ /**
+ * 测试失败流程仍然播报语音
+ */
+ public function testFailedFlowStillSendsVoice(): void
+ {
+ $processor = $this->createTestableProcessor();
+
+ $context = $this->createContext();
+ $context->setError('刷错,清洗剩余120秒');
+ $context->setVoice('刷错,清洗剩余120秒');
+
+ $this->invokeHandleResult($processor, $context);
+
+ $this->assertNotEmpty($processor->voiceSent);
+ }
+
+ /**
+ * 测试成功流程需要 WebSocket 时触发通知
+ */
+ public function testWebSocketNotificationSentWhenNeeded(): void
+ {
+ $processor = $this->createTestableProcessor();
+
+ $context = $this->createContext(['currentStep' => '清洗']);
+ $context->needDatabaseOperation = false;
+ $context->needWebSocketNotify = true;
+
+ $this->invokeHandleResult($processor, $context);
+
+ $this->assertContains('清洗', $processor->wsSent);
+ }
+
+ /**
+ * 测试 needWebSocketNotify=false 时不发送 WebSocket
+ */
+ public function testWebSocketNotificationNotSentWhenNotNeeded(): void
+ {
+ $processor = $this->createTestableProcessor();
+
+ $context = $this->createContext(['currentStep' => '清洗']);
+ $context->needDatabaseOperation = false;
+ $context->needWebSocketNotify = false;
+
+ $this->invokeHandleResult($processor, $context);
+
+ $this->assertEmpty($processor->wsSent);
+ }
+
+ /**
+ * 测试结束操作为 update
+ */
+ public function testEndOperationIsUpdate(): void
+ {
+ $processor = $this->createTestableProcessor();
+
+ $context = $this->createContext([
+ 'currentStep' => '结束',
+ 'batchNo' => 'BATCH001',
+ ]);
+ $context->needDatabaseOperation = true;
+ $context->dbOperation = DbOperationType::UPDATE;
+ $context->actionEndTime = date('Y-m-d H:i:s');
+
+ $this->invokeHandleResult($processor, $context);
+
+ $this->assertNotEmpty($processor->savedContexts);
+ $this->assertEquals(DbOperationType::UPDATE, $processor->savedContexts[0]['operation']);
+ }
+
+ /**
+ * 测试 getActionType 各流程类型映射
+ * 注:getActionType 已移至 EctActionsRepository,此测试验证映射逻辑
+ */
+ public function testGetActionTypeMapping(): void
+ {
+ $repo = \app\repository\EctActionsRepository::new();
+ $method = new \ReflectionMethod($repo, 'mapActionType');
+ $method->setAccessible(true);
+
+ $this->assertEquals(1, $method->invoke($repo, '手工洗'));
+ $this->assertEquals(1, $method->invoke($repo, '手工洗(晨洗)'));
+ $this->assertEquals(1, $method->invoke($repo, '手工洗(加强)'));
+ $this->assertEquals(2, $method->invoke($repo, '机洗'));
+ $this->assertEquals(2, $method->invoke($repo, '机洗(晨洗)'));
+ $this->assertEquals(2, $method->invoke($repo, '机洗(加强)'));
+ $this->assertEquals(7, $method->invoke($repo, '测漏'));
+ $this->assertEquals(8, $method->invoke($repo, '存储'));
+ $this->assertEquals(0, $method->invoke($repo, '诊疗'));
+ // 未知类型 fallback 到 1
+ $this->assertEquals(1, $method->invoke($repo, '未知类型'));
+ }
+
+ /**
+ * 测试 updateConfig 更新引擎配置
+ */
+ public function testUpdateConfig(): void
+ {
+ $processor = $this->createTestableProcessor();
+ $newConfig = ProcessConfig::createMachineWash();
+
+ $processor->updateConfig($newConfig);
+
+ $this->assertFalse($processor->engine->getNode('漂洗')->isEnabled());
+ $this->assertFalse($processor->engine->getNode('消毒')->isEnabled());
+ }
+
+ /**
+ * 测试静态工厂 create
+ */
+ public function testStaticFactoryCreate(): void
+ {
+ $processor = FlowProcessor::create(ProcessConfig::createStandard());
+
+ $this->assertInstanceOf(FlowProcessor::class, $processor);
+ $this->assertInstanceOf(ProcessEngine::class, $processor->engine);
+ }
+
+ /**
+ * 通过反射调用 handleResult(protected 方法)
+ */
+ private function invokeHandleResult(FlowProcessor $processor, ProcessContext $context): void
+ {
+ $method = new \ReflectionMethod($processor, 'handleResult');
+ $method->setAccessible(true);
+ $method->invoke($processor, $context);
+ }
+}
diff --git a/tests/flow/PerformanceTest.php b/tests/flow/PerformanceTest.php
new file mode 100644
index 0000000..07c3523
--- /dev/null
+++ b/tests/flow/PerformanceTest.php
@@ -0,0 +1,274 @@
+createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'endoscopeId' => '',
+ 'morningWashed' => true,
+ ]);
+
+ $startTime = microtime(true);
+ $result = $engine->execute($context);
+ $endTime = microtime(true);
+
+ $executionTime = ($endTime - $startTime) * 1000; // 毫秒
+
+ $this->assertSuccess($result);
+ // 单次执行应该小于 500ms(debug日志有IO开销)
+ $this->assertLessThan(500, $executionTime, "单次执行时间过长: {$executionTime}ms");
+ }
+
+ /**
+ * 测试流程执行性能 - 完整流程
+ */
+ public function testCompleteProcessPerformance(): void
+ {
+ $engine = ProcessEngine::createStandard();
+
+ $steps = [
+ ['readerType' => '清洗', 'prevStep' => '', 'waitTime' => 0],
+ ['readerType' => '漂洗', 'prevStep' => '清洗', 'waitTime' => 400],
+ ['readerType' => '消毒', 'prevStep' => '漂洗', 'waitTime' => 200],
+ ['readerType' => '终末漂洗', 'prevStep' => '消毒', 'waitTime' => 400],
+ ['readerType' => '干燥', 'prevStep' => '终末漂洗', 'waitTime' => 200],
+ ['readerType' => '结束', 'prevStep' => '干燥', 'waitTime' => 300],
+ ];
+
+ $startTime = microtime(true);
+ $batchNo = null;
+
+ foreach ($steps as $step) {
+ $context = $this->createContext([
+ 'readerType' => $step['readerType'],
+ 'currentStep' => $step['prevStep'],
+ 'endoscopeId' => '',
+ 'batchNo' => $batchNo,
+ 'morningWashed' => true,
+ ]);
+
+ if ($step['waitTime'] > 0) {
+ $this->setStepTime($context, $step['prevStep'], $step['waitTime']);
+ }
+
+ $result = $engine->execute($context);
+ $this->assertSuccess($result);
+
+ if ($batchNo === null) {
+ $batchNo = $result->batchNo;
+ }
+ }
+
+ $endTime = microtime(true);
+ $totalTime = ($endTime - $startTime) * 1000; // 毫秒
+
+ // 完整流程(6步)应该小于 5000ms(debug日志有IO开销)
+ $this->assertLessThan(5000, $totalTime, "完整流程执行时间过长: {$totalTime}ms");
+ }
+
+ /**
+ * 测试批次号生成性能
+ */
+ public function testBatchNoGenerationPerformance(): void
+ {
+ $context = $this->createContext(['endoscopeId' => '1']);
+
+ $startTime = microtime(true);
+
+ for ($i = 0; $i < 1000; $i++) {
+ $context->generateBatchNo();
+ }
+
+ $endTime = microtime(true);
+ $totalTime = ($endTime - $startTime) * 1000; // 毫秒
+
+ // 生成1000个批次号应该小于 100ms
+ $this->assertLessThan(100, $totalTime, "批次号生成时间过长: {$totalTime}ms");
+ }
+
+ /**
+ * 测试引擎创建性能
+ */
+ public function testEngineCreationPerformance(): void
+ {
+ $startTime = microtime(true);
+
+ for ($i = 0; $i < 100; $i++) {
+ ProcessEngine::createStandard();
+ }
+
+ $endTime = microtime(true);
+ $totalTime = ($endTime - $startTime) * 1000; // 毫秒
+
+ // 创建100个引擎应该小于 200ms
+ $this->assertLessThan(200, $totalTime, "引擎创建时间过长: {$totalTime}ms");
+ }
+
+ /**
+ * 测试配置加载性能
+ */
+ public function testConfigLoadingPerformance(): void
+ {
+ $startTime = microtime(true);
+
+ for ($i = 0; $i < 1000; $i++) {
+ new ProcessConfig();
+ }
+
+ $endTime = microtime(true);
+ $totalTime = ($endTime - $startTime) * 1000; // 毫秒
+
+ // 创建1000个配置应该小于 100ms
+ $this->assertLessThan(100, $totalTime, "配置加载时间过长: {$totalTime}ms");
+ }
+
+ /**
+ * 测试上下文创建性能
+ */
+ public function testContextCreationPerformance(): void
+ {
+ $startTime = microtime(true);
+
+ for ($i = 0; $i < 10000; $i++) {
+ ProcessContext::create([
+ 'endoscopeId' => '1',
+ 'endoscopeName' => '测试内镜',
+ ]);
+ }
+
+ $endTime = microtime(true);
+ $totalTime = ($endTime - $startTime) * 1000; // 毫秒
+
+ // 创建10000个上下文应该小于 500ms(考虑环境差异)
+ $this->assertLessThan(500, $totalTime, "上下文创建时间过长: {$totalTime}ms");
+ }
+
+ /**
+ * 测试策略执行性能
+ */
+ public function testStrategyExecutionPerformance(): void
+ {
+ $strategy = new \app\flow\strategies\TimeValidationStrategy();
+ $node = new \app\flow\nodes\WashNode();
+
+ $startTime = microtime(true);
+
+ for ($i = 0; $i < 1000; $i++) {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ ]);
+ $strategy->execute($context, $node);
+ }
+
+ $endTime = microtime(true);
+ $totalTime = ($endTime - $startTime) * 1000; // 毫秒
+
+ // 执行1000次策略应该小于 500ms(考虑环境差异)
+ $this->assertLessThan(500, $totalTime, "策略执行时间过长: {$totalTime}ms");
+ }
+
+ /**
+ * 测试内存使用 - 大批量上下文创建
+ */
+ public function testMemoryUsage(): void
+ {
+ $memoryBefore = memory_get_usage(true);
+
+ $contexts = [];
+ for ($i = 0; $i < 10000; $i++) {
+ $contexts[] = ProcessContext::create([
+ 'endoscopeId' => (string)$i,
+ 'endoscopeName' => '测试内镜' . $i,
+ ]);
+ }
+
+ $memoryAfter = memory_get_usage(true);
+ $memoryUsed = ($memoryAfter - $memoryBefore) / 1024 / 1024; // MB
+
+ // 10000个上下文应该使用小于 50MB 内存
+ $this->assertLessThan(50, $memoryUsed, "内存使用过高: {$memoryUsed}MB");
+
+ // 清理
+ unset($contexts);
+ }
+
+ /**
+ * 测试并发场景模拟 - 多内镜同时处理
+ */
+ public function testConcurrentProcessingSimulation(): void
+ {
+ $engine = ProcessEngine::createStandard();
+
+ $startTime = microtime(true);
+
+ // 模拟10个内镜同时开始清洗
+ $results = [];
+ for ($i = 1; $i <= 10; $i++) {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'endoscopeId' => '',
+ 'morningWashed' => true,
+ ]);
+ $results[] = $engine->execute($context);
+ }
+
+ $endTime = microtime(true);
+ $totalTime = ($endTime - $startTime) * 1000; // 毫秒
+
+ // 10个内镜同时处理应该小于 2000ms(debug日志有IO开销)
+ $this->assertLessThan(2000, $totalTime, "并发处理时间过长: {$totalTime}ms");
+
+ // 所有结果应该成功
+ foreach ($results as $result) {
+ $this->assertSuccess($result);
+ }
+ }
+
+ /**
+ * 测试责任链遍历性能
+ */
+ public function testChainTraversalPerformance(): void
+ {
+ $engine = ProcessEngine::createStandard();
+
+ // 使用结束读卡器测试完整链遍历
+ $context = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '干燥',
+ 'morningWashed' => true,
+ ]);
+ $this->setStepTime($context, '干燥', 300);
+
+ $startTime = microtime(true);
+
+ for ($i = 0; $i < 1000; $i++) {
+ $testContext = clone $context;
+ $engine->execute($testContext);
+ }
+
+ $endTime = microtime(true);
+ $totalTime = ($endTime - $startTime) * 1000; // 毫秒
+
+ // 1000次完整链遍历应该小于 30000ms(debug日志有IO开销)
+ $this->assertLessThan(30000, $totalTime, "链遍历时间过长: {$totalTime}ms");
+ }
+}
diff --git a/tests/flow/ProcessContextTest.php b/tests/flow/ProcessContextTest.php
new file mode 100644
index 0000000..c230fd6
--- /dev/null
+++ b/tests/flow/ProcessContextTest.php
@@ -0,0 +1,182 @@
+ '123',
+ 'endoscopeName' => '测试内镜',
+ 'currentStep' => '清洗',
+ ]);
+
+ $this->assertEquals('123', $context->endoscopeId);
+ $this->assertEquals('测试内镜', $context->endoscopeName);
+ $this->assertEquals('清洗', $context->currentStep);
+ $this->assertTrue($context->success);
+ }
+
+ /**
+ * 测试设置错误状态
+ */
+ public function testSetError(): void
+ {
+ $context = $this->createContext();
+
+ $context->setError('测试错误');
+
+ $this->assertFalse($context->success);
+ $this->assertEquals('测试错误', $context->errorMessage);
+ }
+
+ /**
+ * 测试设置语音
+ */
+ public function testSetVoice(): void
+ {
+ $context = $this->createContext();
+
+ $context->setVoice('清洗完成');
+
+ $this->assertEquals('清洗完成', $context->voiceMessage);
+ }
+
+ /**
+ * 测试获取完整语音
+ */
+ public function testGetFullVoice(): void
+ {
+ $context = $this->createContext([
+ 'endoscopeName' => '北院电子胃镜001',
+ ]);
+
+ $context->setVoice('清洗完成');
+
+ $fullVoice = $context->getFullVoice();
+
+ // 当前实现返回的是语音消息本身,不包含内镜名称
+ $this->assertEquals('清洗完成', $fullVoice);
+ }
+
+ /**
+ * 测试步骤时间管理
+ */
+ public function testStepTimeManagement(): void
+ {
+ $context = $this->createContext();
+
+ $time = date('Y-m-d H:i:s');
+ $context->setStepLastTime('清洗', $time);
+
+ $this->assertEquals($time, $context->getStepLastTime('清洗'));
+ $this->assertNull($context->getStepLastTime('消毒'));
+ }
+
+ /**
+ * 测试步骤时长管理
+ */
+ public function testStepDurationManagement(): void
+ {
+ $context = $this->createContext();
+
+ // 设置自定义时长
+ $context->setStepDuration('清洗', 600);
+
+ $this->assertEquals(600, $context->getStepDuration('清洗'));
+ }
+
+ /**
+ * 测试默认步骤时长
+ */
+ public function testDefaultStepDuration(): void
+ {
+ $context = $this->createContext();
+
+ // 测试默认值(与 ect_meta_process 表数据对齐)
+ $this->assertEquals(120, $context->getStepDuration('清洗')); // 手工洗最短 60s,机洗最短 120s,取保守值
+ $this->assertEquals(60, $context->getStepDuration('漂洗')); // 60s
+ $this->assertEquals(300, $context->getStepDuration('消毒')); // 300s
+ $this->assertEquals(0, $context->getStepDuration('不存在的步骤'));
+ }
+
+ /**
+ * 测试批次号生成
+ */
+ public function testGenerateBatchNo(): void
+ {
+ $context = $this->createContext([
+ 'endoscopeId' => '123',
+ ]);
+
+ $batchNo = $context->generateBatchNo();
+
+ // 验证格式:YYYYMMDDHHMMSS + 内镜ID(6位) + 随机数(4位)
+ $this->assertEquals(24, strlen($batchNo));
+ $this->assertMatchesRegularExpression('/^\d{14}\d{6}\d{4}$/', $batchNo);
+ }
+
+ /**
+ * 测试批次号唯一性
+ */
+ public function testBatchNoUniqueness(): void
+ {
+ $context = $this->createContext();
+
+ $batchNo1 = $context->generateBatchNo();
+ $batchNo2 = $context->generateBatchNo();
+
+ $this->assertNotEquals($batchNo1, $batchNo2);
+ }
+
+ /**
+ * 测试是否可以开始新流程
+ */
+ public function testCanStartNewProcess(): void
+ {
+ // 可以开始新流程的状态
+ $this->assertTrue($this->createContext(['currentStep' => ''])->canStartNewProcess());
+ $this->assertTrue($this->createContext(['currentStep' => '结束'])->canStartNewProcess());
+ $this->assertTrue($this->createContext(['currentStep' => '内镜取出'])->canStartNewProcess());
+ $this->assertTrue($this->createContext(['currentStep' => '测漏正常'])->canStartNewProcess());
+
+ // 不可以开始新流程的状态
+ $this->assertFalse($this->createContext(['currentStep' => '清洗'])->canStartNewProcess());
+ $this->assertFalse($this->createContext(['currentStep' => '消毒'])->canStartNewProcess());
+ }
+
+ /**
+ * 测试流程完成状态
+ */
+ public function testIsWashProcessCompleted(): void
+ {
+ $this->assertTrue($this->createContext(['currentStep' => '结束'])->isWashProcessCompleted());
+ $this->assertFalse($this->createContext(['currentStep' => '清洗'])->isWashProcessCompleted());
+ $this->assertFalse($this->createContext(['currentStep' => ''])->isWashProcessCompleted());
+ }
+
+ /**
+ * 测试链式调用
+ */
+ public function testChaining(): void
+ {
+ $context = $this->createContext();
+
+ $result = $context
+ ->setVoice('测试')
+ ->setStepLastTime('清洗', date('Y-m-d H:i:s'))
+ ->setStepDuration('清洗', 300);
+
+ $this->assertSame($context, $result);
+ $this->assertEquals('测试', $context->voiceMessage);
+ }
+}
diff --git a/tests/flow/ProcessEngineTest.php b/tests/flow/ProcessEngineTest.php
new file mode 100644
index 0000000..3860cf5
--- /dev/null
+++ b/tests/flow/ProcessEngineTest.php
@@ -0,0 +1,292 @@
+engine = ProcessEngine::createStandard();
+ }
+
+ /**
+ * 测试创建标准流程引擎
+ */
+ public function testCreateStandardEngine(): void
+ {
+ $engine = ProcessEngine::createStandard();
+
+ $this->assertInstanceOf(ProcessEngine::class, $engine);
+ $this->assertNotNull($engine->getNode('清洗'));
+ $this->assertNotNull($engine->getNode('消毒'));
+ $this->assertNotNull($engine->getNode('结束'));
+ }
+
+ /**
+ * 测试创建无晨洗流程引擎
+ */
+ public function testCreateNoMorningWashEngine(): void
+ {
+ $engine = ProcessEngine::createNoMorningWash();
+
+ $this->assertFalse($engine->getNode('晨洗')->isEnabled());
+ }
+
+ /**
+ * 测试创建简化流程引擎
+ */
+ public function testCreateSimpleEngine(): void
+ {
+ $engine = ProcessEngine::createSimple();
+
+ $this->assertTrue($engine->getNode('清洗')->isEnabled());
+ $this->assertFalse($engine->getNode('漂洗')->isEnabled());
+ $this->assertFalse($engine->getNode('消毒')->isEnabled());
+ $this->assertTrue($engine->getNode('结束')->isEnabled());
+ }
+
+ /**
+ * 测试完整清洗流程
+ */
+ public function testCompleteWashProcess(): void
+ {
+ // 1. 开始清洗
+ $context1 = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'morningWashed' => true,
+ 'endoscopeId' => '', // 避免触发 DB 查询
+ ]);
+ $result1 = $this->engine->execute($context1);
+ $this->assertSuccess($result1);
+ $this->assertStep($result1, '清洗');
+ $batchNo = $result1->batchNo;
+
+ // 2. 漂洗
+ $context2 = $this->createContext([
+ 'readerType' => '漂洗',
+ 'currentStep' => '清洗',
+ 'batchNo' => $batchNo,
+ 'morningWashed' => true,
+ ]);
+ $this->setStepTime($context2, '清洗', 400); // 超过5分钟
+ $result2 = $this->engine->execute($context2);
+ $this->assertSuccess($result2);
+ $this->assertStep($result2, '漂洗');
+ $this->assertEquals($batchNo, $result2->batchNo); // 批次号一致
+
+ // 3. 消毒
+ $context3 = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '漂洗',
+ 'batchNo' => $batchNo,
+ 'morningWashed' => true,
+ ]);
+ $this->setStepTime($context3, '漂洗', 200); // 超过2分钟
+ $result3 = $this->engine->execute($context3);
+ $this->assertSuccess($result3);
+ $this->assertStep($result3, '消毒');
+
+ // 4. 终末漂洗
+ $context4 = $this->createContext([
+ 'readerType' => '终末漂洗',
+ 'currentStep' => '消毒',
+ 'batchNo' => $batchNo,
+ 'morningWashed' => true,
+ ]);
+ $this->setStepTime($context4, '消毒', 400); // 超过5分钟
+ $result4 = $this->engine->execute($context4);
+ $this->assertSuccess($result4);
+ $this->assertStep($result4, '终末漂洗');
+
+ // 5. 干燥
+ $context5 = $this->createContext([
+ 'readerType' => '干燥',
+ 'currentStep' => '终末漂洗',
+ 'batchNo' => $batchNo,
+ 'morningWashed' => true,
+ ]);
+ $this->setStepTime($context5, '终末漂洗', 200); // 超过2分钟
+ $result5 = $this->engine->execute($context5);
+ $this->assertSuccess($result5);
+ $this->assertStep($result5, '干燥');
+
+ // 6. 结束
+ $context6 = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '干燥',
+ 'batchNo' => $batchNo,
+ 'morningWashed' => true,
+ ]);
+ $this->setStepTime($context6, '干燥', 300); // 超过3分钟
+ $result6 = $this->engine->execute($context6);
+ $this->assertSuccess($result6);
+ $this->assertStep($result6, '结束');
+ }
+
+ /**
+ * 测试时间验证失败
+ */
+ public function testTimeValidationFailure(): void
+ {
+ // 显式设置清洗时长为 300s
+ $strategy = new \app\flow\strategies\TimeValidationStrategy(['durations' => ['清洗' => 300]]);
+ $node = new \app\flow\nodes\WashNode();
+
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '清洗',
+ 'morningWashed' => true,
+ ]);
+
+ // 设置2分钟前的时间(不足5分钟)
+ $this->setStepTime($context, '清洗', 120);
+
+ $result = $strategy->execute($context, $node);
+
+ $this->assertFailure($result, '刷错,清洗剩余');
+ }
+
+ /**
+ * 测试标准流程:清洗 -> 漂洗 -> 消毒
+ */
+ public function testStandardWashRinseDisinfectFlow(): void
+ {
+ // 1. 清洗
+ $context1 = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'morningWashed' => true,
+ 'needMorningWash' => false,
+ 'endoscopeId' => '',
+ ]);
+ $result1 = $this->engine->execute($context1);
+ $this->assertSuccess($result1);
+ $this->assertEquals('清洗', $result1->currentStep);
+
+ // 2. 漂洗
+ $context2 = $this->createContext([
+ 'readerType' => '漂洗',
+ 'currentStep' => '清洗',
+ 'batchNo' => $result1->batchNo,
+ 'morningWashed' => true,
+ 'needMorningWash' => false,
+ ]);
+ $this->setStepTime($context2, '清洗', 400);
+ $result2 = $this->engine->execute($context2);
+ $this->assertSuccess($result2);
+ $this->assertEquals('漂洗', $result2->currentStep);
+
+ // 3. 消毒
+ $context3 = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '漂洗',
+ 'batchNo' => $result1->batchNo,
+ 'morningWashed' => true,
+ 'needMorningWash' => false,
+ ]);
+ $this->setStepTime($context3, '漂洗', 200);
+ $result3 = $this->engine->execute($context3);
+ $this->assertSuccess($result3);
+ $this->assertEquals('消毒', $result3->currentStep);
+ }
+
+ /**
+ * 测试禁用节点
+ */
+ public function testDisableNode(): void
+ {
+ $this->engine->disableNode('干燥');
+
+ $this->assertFalse($this->engine->getNode('干燥')->isEnabled());
+
+ // 恢复
+ $this->engine->enableNode('干燥');
+ $this->assertTrue($this->engine->getNode('干燥')->isEnabled());
+ }
+
+ /**
+ * 测试获取所有节点
+ */
+ public function testGetNodes(): void
+ {
+ $nodes = $this->engine->getNodes();
+
+ $this->assertArrayHasKey('清洗', $nodes);
+ $this->assertArrayHasKey('消毒', $nodes);
+ $this->assertArrayHasKey('结束', $nodes);
+ }
+
+ /**
+ * 测试获取启用的节点
+ */
+ public function testGetEnabledNodes(): void
+ {
+ $this->engine->disableNode('干燥');
+
+ $enabledNodes = $this->engine->getEnabledNodes();
+
+ $this->assertArrayNotHasKey('干燥', $enabledNodes);
+ $this->assertArrayHasKey('清洗', $enabledNodes);
+ }
+
+ /**
+ * 测试更新配置
+ */
+ public function testUpdateConfig(): void
+ {
+ $newConfig = ProcessConfig::createNoMorningWash();
+
+ $this->engine->updateConfig($newConfig);
+
+ $this->assertFalse($this->engine->getNode('晨洗')->isEnabled());
+ }
+
+ /**
+ * 测试机洗流程
+ */
+ public function testMachineWashProcess(): void
+ {
+ $engine = ProcessEngine::createMachineWash();
+
+ // 1. 清洗
+ $context1 = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'endoscopeId' => '',
+ ]);
+ $result1 = $engine->execute($context1);
+ $this->assertSuccess($result1);
+ $this->assertStep($result1, '清洗');
+
+ // 2. 机洗
+ $context2 = $this->createContext([
+ 'readerType' => '机洗',
+ 'currentStep' => '清洗',
+ 'batchNo' => $result1->batchNo,
+ ]);
+ $this->setStepTime($context2, '清洗', 400);
+ $result2 = $engine->execute($context2);
+ $this->assertSuccess($result2);
+ $this->assertStep($result2, '机洗');
+ $this->assertEquals('机洗', $result2->processType);
+
+ // 3. 终末漂洗
+ $context3 = $this->createContext([
+ 'readerType' => '终末漂洗',
+ 'currentStep' => '机洗',
+ 'batchNo' => $result1->batchNo,
+ ]);
+ $result3 = $engine->execute($context3);
+ $this->assertSuccess($result3);
+ $this->assertStep($result3, '终末漂洗');
+ }
+}
diff --git a/tests/flow/TestCase.php b/tests/flow/TestCase.php
new file mode 100644
index 0000000..875ed19
--- /dev/null
+++ b/tests/flow/TestCase.php
@@ -0,0 +1,81 @@
+ '1',
+ 'endoscopeName' => '测试胃镜',
+ 'cardNo' => '04A2AD88D0',
+ 'readerNo' => '09E45F217B',
+ 'readerType' => '',
+ 'currentStep' => '',
+ 'processType' => '',
+ 'needMorningWash' => false,
+ 'morningWashed' => false, // 默认未完成晨洗,由具体测试覆盖
+ 'todayWashRecords' => 0,
+ ];
+
+ return ProcessContext::create(array_merge($defaults, $data));
+ }
+
+ /**
+ * 断言流程执行成功
+ */
+ protected function assertSuccess(ProcessContext $context): void
+ {
+ $this->assertTrue($context->success,
+ '流程执行失败: ' . $context->errorMessage);
+ }
+
+ /**
+ * 断言流程执行失败
+ */
+ protected function assertFailure(ProcessContext $context, string $expectedMessage = ''): void
+ {
+ $this->assertFalse($context->success, '期望流程执行失败,但成功了');
+
+ if ($expectedMessage) {
+ $this->assertStringContainsString($expectedMessage, $context->errorMessage);
+ }
+ }
+
+ /**
+ * 断言当前步骤
+ */
+ protected function assertStep(ProcessContext $context, string $expectedStep): void
+ {
+ $this->assertEquals($expectedStep, $context->currentStep,
+ "期望当前步骤为 {$expectedStep},实际是 {$context->currentStep}");
+ }
+
+ /**
+ * 断言语音内容
+ */
+ protected function assertVoiceContains(ProcessContext $context, string $expectedText): void
+ {
+ $this->assertStringContainsString($expectedText, $context->voiceMessage,
+ "语音内容应包含 '{$expectedText}'");
+ }
+
+ /**
+ * 设置步骤时间(用于时间验证测试)
+ */
+ protected function setStepTime(ProcessContext $context, string $stepCode, int $secondsAgo): void
+ {
+ $time = date('Y-m-d H:i:s', time() - $secondsAgo);
+ $context->setStepLastTime($stepCode, $time);
+ }
+}
diff --git a/tests/flow/UsageExampleTest.php b/tests/flow/UsageExampleTest.php
new file mode 100644
index 0000000..9f6c3bc
--- /dev/null
+++ b/tests/flow/UsageExampleTest.php
@@ -0,0 +1,511 @@
+ '04A2AD88D0',
+ 'readerNo' => '09E45F217B',
+ 'readerType' => '清洗',
+ 'endoscopeId' => '',
+ 'endoscopeName' => '胃镜001',
+ 'currentStep' => '',
+ 'morningWashed' => true,
+ 'needMorningWash' => false,
+ ]);
+
+ // 3. 执行流程
+ $result = $processor->engine->execute($context);
+
+ // 4. 验证处理结果
+ $this->assertTrue($result->success);
+ $this->assertEquals('清洗', $result->currentStep);
+ $this->assertNotEmpty($result->voiceMessage);
+ }
+
+ /**
+ * 测试使用 FlowProcessor 处理完整流程
+ */
+ public function testFlowProcessorCompleteProcess(): void
+ {
+ $processor = FlowProcessor::create(ProcessConfig::createStandard());
+
+ // 步骤1: 清洗
+ $context1 = ProcessContext::create([
+ 'cardNo' => '04A2AD88D0',
+ 'readerNo' => '09E45F217B',
+ 'readerType' => '清洗',
+ 'endoscopeId' => '',
+ 'endoscopeName' => '胃镜001',
+ 'currentStep' => '',
+ 'morningWashed' => true,
+ 'needMorningWash' => false,
+ ]);
+ $result1 = $processor->engine->execute($context1);
+ $this->assertTrue($result1->success);
+ $this->assertEquals('清洗', $result1->currentStep);
+ $batchNo = $result1->batchNo;
+
+ // 步骤2: 漂洗(模拟时间已过)
+ $context2 = ProcessContext::create([
+ 'cardNo' => '04A2AD88D0',
+ 'readerNo' => '09E45F217B',
+ 'readerType' => '漂洗',
+ 'endoscopeId' => '1',
+ 'endoscopeName' => '胃镜001',
+ 'currentStep' => '清洗',
+ 'batchNo' => $batchNo,
+ 'morningWashed' => true,
+ 'needMorningWash' => false,
+ ]);
+ // 设置清洗步骤时间已满足
+ $context2->setStepLastTime('清洗', date('Y-m-d H:i:s', time() - 360));
+ $result2 = $processor->engine->execute($context2);
+ $this->assertTrue($result2->success);
+ $this->assertEquals('漂洗', $result2->currentStep);
+ }
+
+ /**
+ * 测试使用 Config 配置创建 FlowProcessor
+ */
+ public function testFlowProcessorWithConfig(): void
+ {
+ // 从全局配置获取无晨洗配置
+ $globalConfig = Config::getInstance();
+ $hospitalConfig = $globalConfig->customProcess['no_morning_wash'] ?? null;
+
+ if ($hospitalConfig === null) {
+ $this->markTestSkipped('no_morning_wash 配置不存在');
+ }
+
+ // 使用配置创建流程处理器
+ $processConfig = ProcessConfig::fromArray($hospitalConfig);
+ $processor = new FlowProcessor($processConfig);
+
+ // 验证配置生效
+ $this->assertFalse($processor->engine->getNode('晨洗')->isEnabled());
+
+ // 处理刷卡
+ $context = ProcessContext::create([
+ 'cardNo' => '04A2AD88D0',
+ 'readerNo' => '09E45F217B',
+ 'readerType' => '清洗',
+ 'endoscopeId' => '',
+ 'endoscopeName' => '胃镜001',
+ 'currentStep' => '',
+ 'morningWashed' => true,
+ 'needMorningWash' => false,
+ ]);
+ $result = $processor->engine->execute($context);
+ $this->assertTrue($result->success);
+ }
+
+ /**
+ * 测试从 Config 全局配置加载自定义流程
+ */
+ public function testLoadCustomProcessFromConfig(): void
+ {
+ // 获取全局配置实例
+ $globalConfig = Config::getInstance();
+
+ // 验证自定义流程配置已加载
+ $this->assertNotEmpty($globalConfig->customProcess);
+ $this->assertArrayHasKey('standard', $globalConfig->customProcess);
+ $this->assertArrayHasKey('no_morning_wash', $globalConfig->customProcess);
+ $this->assertArrayHasKey('machine_wash', $globalConfig->customProcess);
+ }
+
+ /**
+ * 测试使用 Config 中的无晨洗配置创建流程
+ */
+ public function testCreateNoMorningWashFromConfig(): void
+ {
+ $globalConfig = Config::getInstance();
+
+ // 从全局配置获取无晨洗配置
+ $hospitalConfig = $globalConfig->customProcess['no_morning_wash'] ?? null;
+
+ // 如果配置存在则测试
+ if ($hospitalConfig === null) {
+ $this->markTestSkipped('no_morning_wash 配置不存在');
+ }
+
+ // 使用配置创建流程
+ $processConfig = ProcessConfig::fromArray($hospitalConfig);
+ $engine = new ProcessEngine($processConfig);
+
+ // 验证晨洗节点被禁用
+ $this->assertFalse($engine->getNode('晨洗')->isEnabled());
+
+ // 执行流程
+ $context = ProcessContext::create([
+ 'endoscopeName' => '测试内镜',
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'needMorningWash' => false,
+ 'morningWashed' => true,
+ ]);
+
+ $result = $engine->execute($context);
+ $this->assertTrue($result->success);
+ }
+
+ /**
+ * 测试使用 Config 中的机洗配置创建流程
+ */
+ public function testCreateMachineWashFromConfig(): void
+ {
+ $globalConfig = Config::getInstance();
+
+ // 从全局配置获取机洗配置
+ $hospitalConfig = $globalConfig->customProcess['machine_wash'] ?? null;
+
+ // 如果配置存在则测试
+ if ($hospitalConfig === null) {
+ $this->markTestSkipped('machine_wash 配置不存在');
+ }
+
+ // 使用配置创建流程
+ $processConfig = ProcessConfig::fromArray($hospitalConfig);
+ $engine = new ProcessEngine($processConfig);
+
+ // 验证机洗节点启用,漂洗和消毒禁用
+ $this->assertTrue($engine->getNode('机洗')->isEnabled());
+ $this->assertFalse($engine->getNode('漂洗')->isEnabled());
+ $this->assertFalse($engine->getNode('消毒')->isEnabled());
+
+ // 执行机洗流程
+ $context = ProcessContext::create([
+ 'endoscopeName' => '测试内镜',
+ 'readerType' => '机洗',
+ 'currentStep' => '清洗',
+ 'morningWashed' => true,
+ 'needMorningWash' => false,
+ ]);
+
+ $result = $engine->execute($context);
+ $this->assertTrue($result->success);
+ $this->assertEquals('机洗', $result->currentStep);
+ }
+
+ /**
+ * 测试使用 Config 中的义乌模式配置
+ */
+ public function testCreateYiwuModeFromConfig(): void
+ {
+ $globalConfig = Config::getInstance();
+
+ // 从全局配置获取义乌模式配置(按存储时间判断晨洗)
+ $hospitalConfig = $globalConfig->customProcess['partial_morning_wash'] ?? null;
+
+ // 如果配置存在则测试
+ if ($hospitalConfig === null) {
+ $this->markTestSkipped('partial_morning_wash 配置不存在');
+ }
+
+ // 验证配置内容
+ $this->assertEquals('storage_time', $hospitalConfig['morning_wash']['mode'] ?? '');
+ $this->assertEquals(4, $hospitalConfig['morning_wash']['storage_threshold'] ?? 0);
+
+ // 使用配置创建流程
+ $processConfig = ProcessConfig::fromArray($hospitalConfig);
+ $engine = new ProcessEngine($processConfig);
+
+ // 验证晨洗节点启用
+ $this->assertTrue($engine->getNode('晨洗')->isEnabled());
+ }
+
+ /**
+ * 测试标准流程处理
+ */
+ public function testExample1StandardProcess(): void
+ {
+ // 创建标准流程引擎
+ $engine = ProcessEngine::createStandard();
+
+ // 创建流程上下文(模拟刷卡数据)
+ $context = ProcessContext::create([
+ 'endoscopeId' => '',
+ 'endoscopeName' => '胃镜001',
+ 'cardNo' => '04A2AD88D0',
+ 'readerNo' => '09E45F217B',
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'needMorningWash' => false,
+ 'morningWashed' => true,
+ ]);
+
+ // 执行流程
+ $result = $engine->execute($context);
+
+ // 验证结果
+ $this->assertTrue($result->success);
+ $this->assertEquals('清洗', $result->currentStep);
+ $this->assertNotEmpty($result->getFullVoice());
+ }
+
+ /**
+ * 测试无晨洗流程配置
+ */
+ public function testExample2NoMorningWash(): void
+ {
+ $config = ProcessConfig::createNoMorningWash();
+ $engine = new ProcessEngine($config);
+
+ // 验证晨洗节点被禁用
+ $this->assertFalse($engine->getNode('晨洗')->isEnabled());
+
+ // 执行流程
+ $context = ProcessContext::create([
+ 'endoscopeName' => '肠镜002',
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'needMorningWash' => false,
+ 'morningWashed' => true,
+ ]);
+
+ $result = $engine->execute($context);
+ $this->assertTrue($result->success);
+ $this->assertEquals('清洗', $result->currentStep);
+ }
+
+ /**
+ * 测试运行时动态调整流程
+ */
+ public function testExample3DynamicAdjust(): void
+ {
+ $engine = ProcessEngine::createStandard();
+
+ // 禁用干燥和终末漂洗步骤
+ $engine->disableNode('干燥');
+ $engine->disableNode('终末漂洗');
+
+ $this->assertFalse($engine->getNode('干燥')->isEnabled());
+ $this->assertFalse($engine->getNode('终末漂洗')->isEnabled());
+
+ // 重新启用
+ $engine->enableNode('干燥');
+ $this->assertTrue($engine->getNode('干燥')->isEnabled());
+ }
+
+ /**
+ * 测试自定义语音
+ */
+ public function testExample4CustomVoice(): void
+ {
+ $engine = ProcessEngine::createStandard();
+
+ // 设置自定义语音
+ $engine->setStepVoice('清洗', '第一步清洗开始,请认真清洗');
+
+ $context = ProcessContext::create([
+ 'endoscopeName' => '胃镜004',
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'morningWashed' => true,
+ 'needMorningWash' => false,
+ ]);
+
+ $result = $engine->execute($context);
+
+ $this->assertTrue($result->success);
+ // 验证语音包含步骤完成信息
+ $this->assertStringContainsString('清洗', $result->getFullVoice());
+ }
+
+ /**
+ * 测试不同晨洗模式
+ */
+ public function testExample5MorningWashModes(): void
+ {
+ // 模式1: 不需要晨洗
+ $config1 = ProcessConfig::createNoMorningWash();
+ $this->assertFalse($config1->isNodeEnabled('晨洗'));
+
+ // 模式2: 义乌模式(按存储时间)
+ $config2 = new ProcessConfig([
+ 'morning_wash' => [
+ 'mode' => 'storage_time',
+ 'storage_threshold' => 4,
+ ],
+ ]);
+ $this->assertEquals('StorageTime', $config2->getMorningWashConfig()->mode->name);
+
+ // 模式3: 特定类型镜子需要晨洗
+ $config3 = new ProcessConfig([
+ 'morning_wash' => [
+ 'mode' => 'specific_types',
+ 'specific_types' => ['胃镜', '十二指肠镜'],
+ ],
+ ]);
+ $this->assertEquals(['胃镜', '十二指肠镜'], $config3->getMorningWashConfig()->getExpand('specific_types', []));
+ }
+
+ /**
+ * 测试机洗流程
+ */
+ public function testExample6MachineWash(): void
+ {
+ $config = ProcessConfig::createMachineWash();
+ $engine = new ProcessEngine($config);
+
+ // 场景: 清洗后刷机洗
+ $context = ProcessContext::create([
+ 'endoscopeName' => '胃镜005',
+ 'readerType' => '机洗',
+ 'currentStep' => '清洗',
+ 'processType' => '手工洗',
+ 'morningWashed' => true,
+ 'needMorningWash' => false,
+ ]);
+
+ $result = $engine->execute($context);
+
+ $this->assertTrue($result->success);
+ $this->assertEquals('机洗', $result->currentStep);
+ $this->assertEquals('机洗', $result->processType);
+ }
+
+ /**
+ * 测试简化流程(只清洗)
+ */
+ public function testExample7SimpleProcess(): void
+ {
+ $config = ProcessConfig::createSimple();
+ $engine = new ProcessEngine($config);
+
+ // 验证漂洗和消毒被禁用
+ $this->assertFalse($engine->getNode('漂洗')->isEnabled());
+ $this->assertFalse($engine->getNode('消毒')->isEnabled());
+
+ $context = ProcessContext::create([
+ 'endoscopeName' => '胃镜006',
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'morningWashed' => true,
+ 'needMorningWash' => false,
+ ]);
+
+ $result = $engine->execute($context);
+ $this->assertTrue($result->success);
+ $this->assertEquals('清洗', $result->currentStep);
+ }
+
+ /**
+ * 测试时间验证 - 正常流程(时间足够)
+ */
+ public function testExample8TimeValidation(): void
+ {
+ $engine = ProcessEngine::createStandard();
+
+ // 先执行清洗
+ $context1 = ProcessContext::create([
+ 'endoscopeName' => '胃镜007',
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'morningWashed' => true,
+ 'needMorningWash' => false,
+ ]);
+ $result1 = $engine->execute($context1);
+ $this->assertTrue($result1->success);
+
+ // 清洗后时间足够(超过5分钟)再刷漂洗
+ $context2 = ProcessContext::create([
+ 'endoscopeName' => '胃镜007',
+ 'readerType' => '漂洗',
+ 'currentStep' => '清洗',
+ 'batchNo' => $result1->batchNo,
+ 'morningWashed' => true,
+ 'needMorningWash' => false,
+ ]);
+ // 设置清洗步骤时间为6分钟前(超过要求的5分钟)
+ $context2->setStepLastTime('清洗', date('Y-m-d H:i:s', time() - 360));
+
+ $result2 = $engine->execute($context2);
+
+ // 时间足够,流程应该成功
+ $this->assertTrue($result2->success);
+ $this->assertEquals('漂洗', $result2->currentStep);
+ }
+
+ /**
+ * 测试完整的刷卡处理流程
+ */
+ public function testExample9FullProcess(): void
+ {
+ // 1. 创建标准流程引擎
+ $engine = ProcessEngine::createStandard();
+
+ // 2. 模拟接收刷卡数据
+ $cardData = [
+ 'cardNo' => '04A2AD88D0',
+ 'readerNo' => '09E45F217B',
+ 'readerType' => '清洗',
+ ];
+
+ // 3. 模拟查询内镜信息
+ $endoscopeInfo = [
+ 'endoscopeId' => '',
+ 'endoscopeName' => '胃镜001',
+ 'currentStep' => '',
+ 'needMorningWash' => false,
+ 'morningWashed' => true,
+ ];
+
+ // 4. 创建上下文
+ $context = ProcessContext::create(array_merge($cardData, $endoscopeInfo));
+
+ // 5. 执行流程
+ $result = $engine->execute($context);
+
+ // 6. 验证结果
+ $this->assertTrue($result->success);
+ $this->assertEquals('清洗', $result->currentStep);
+ $this->assertTrue($result->needDatabaseOperation);
+ $this->assertTrue($result->needWebSocketNotify);
+ }
+
+ /**
+ * 测试多医院配置
+ */
+ public function testExample10MultiHospital(): void
+ {
+ // 医院A使用标准流程
+ $hospitalA = ProcessEngine::createStandard();
+ $this->assertTrue($hospitalA->getNode('晨洗')->isEnabled());
+
+ // 医院B使用无晨洗流程
+ $hospitalB = ProcessEngine::createNoMorningWash();
+ $this->assertFalse($hospitalB->getNode('晨洗')->isEnabled());
+
+ // 医院C使用机洗流程
+ $hospitalC = ProcessEngine::createMachineWash();
+ $this->assertTrue($hospitalC->getNode('机洗')->isEnabled());
+
+ // 医院D使用简化流程
+ $hospitalD = ProcessEngine::createSimple();
+ $this->assertFalse($hospitalD->getNode('漂洗')->isEnabled());
+ }
+}
diff --git a/tests/flow/config/ProcessConfigTest.php b/tests/flow/config/ProcessConfigTest.php
new file mode 100644
index 0000000..c52da7d
--- /dev/null
+++ b/tests/flow/config/ProcessConfigTest.php
@@ -0,0 +1,278 @@
+getSteps();
+ $this->assertNotEmpty($steps);
+ $this->assertArrayHasKey('清洗', array_column($steps, null, 'code'));
+ $this->assertArrayHasKey('消毒', array_column($steps, null, 'code'));
+ }
+
+ /**
+ * 测试从数组加载配置
+ */
+ public function testFromArray(): void
+ {
+ $data = [
+ 'steps' => [
+ ['code' => '清洗', 'class' => 'WashNode', 'enabled' => true],
+ ['code' => '结束', 'class' => 'EndNode', 'enabled' => true],
+ ],
+ 'morning_wash' => ['mode' => 'none'],
+ 'time_validation' => [
+ 'durations' => ['清洗' => 600],
+ ],
+ ];
+
+ $config = ProcessConfig::fromArray($data);
+
+ $this->assertEquals('none', $config->getMorningWashConfig()['mode']);
+ $this->assertEquals(600, $config->getTimeValidationConfig()['durations']['清洗']);
+ }
+
+ /**
+ * 测试获取启用的步骤
+ */
+ public function testGetEnabledSteps(): void
+ {
+ $config = new ProcessConfig();
+ $config->skipStep('漂洗');
+ $config->skipStep('干燥');
+
+ $enabledSteps = $config->getEnabledSteps();
+ $stepCodes = array_column($enabledSteps, 'code');
+
+ $this->assertNotContains('漂洗', $stepCodes);
+ $this->assertNotContains('干燥', $stepCodes);
+ $this->assertContains('清洗', $stepCodes);
+ $this->assertContains('消毒', $stepCodes);
+ }
+
+ /**
+ * 测试添加步骤
+ */
+ public function testAddStep(): void
+ {
+ $config = new ProcessConfig();
+ $config->addStep('自定义步骤', 'CustomNode');
+
+ $step = $config->getStep('自定义步骤');
+ $this->assertNotNull($step);
+ $this->assertEquals('CustomNode', $step['class']);
+ $this->assertTrue($step['enabled']);
+ }
+
+ /**
+ * 测试移除步骤
+ */
+ public function testRemoveStep(): void
+ {
+ $config = new ProcessConfig();
+ $config->removeStep('漂洗');
+
+ $this->assertNull($config->getStep('漂洗'));
+ }
+
+ /**
+ * 测试设置节点启用状态
+ */
+ public function testSetNodeEnabled(): void
+ {
+ $config = new ProcessConfig();
+
+ $this->assertTrue($config->isNodeEnabled('清洗'));
+
+ $config->setNodeEnabled('清洗', false);
+ $this->assertFalse($config->isNodeEnabled('清洗'));
+
+ $config->setNodeEnabled('清洗', true);
+ $this->assertTrue($config->isNodeEnabled('清洗'));
+ }
+
+ /**
+ * 测试晨洗配置
+ */
+ public function testMorningWashConfig(): void
+ {
+ $config = new ProcessConfig();
+
+ // 默认配置
+ $defaultConfig = $config->getMorningWashConfig();
+ $this->assertEquals('daily_first', $defaultConfig['mode']);
+
+ // 修改配置
+ $config->setMorningWashMode('all');
+ $this->assertEquals('all', $config->getMorningWashConfig()['mode']);
+
+ // 设置完整配置
+ $config->setMorningWashConfig([
+ 'mode' => 'specific_types',
+ 'specific_types' => ['胃镜', '肠镜'],
+ ]);
+ $morningConfig = $config->getMorningWashConfig();
+ $this->assertEquals('specific_types', $morningConfig['mode']);
+ $this->assertEquals(['胃镜', '肠镜'], $morningConfig['specific_types']);
+ }
+
+ /**
+ * 测试时间验证配置
+ */
+ public function testTimeValidationConfig(): void
+ {
+ $config = new ProcessConfig();
+
+ $config->setStepDuration('清洗', 600);
+ $durations = $config->getTimeValidationConfig()['durations'];
+
+ $this->assertEquals(600, $durations['清洗']);
+ }
+
+ /**
+ * 测试语音模板配置
+ */
+ public function testVoiceTemplateConfig(): void
+ {
+ $config = new ProcessConfig();
+
+ $config->setStepVoice('normal_wash', '清洗', '自定义清洗语音');
+ $config->setStepVoice('normal_wash', '消毒', '自定义消毒语音');
+
+ $templates = $config->getVoiceTemplates();
+ $this->assertEquals('自定义清洗语音', $templates['normal_wash']['清洗']);
+ $this->assertEquals('自定义消毒语音', $templates['normal_wash']['消毒']);
+ }
+
+ /**
+ * 测试设置步骤自定义语音
+ */
+ public function testSetStepVoice(): void
+ {
+ $config = new ProcessConfig();
+ $config->setStepVoice('normal_wash', '清洗', '请开始清洗操作');
+
+ $templates = $config->getVoiceTemplates();
+ $this->assertEquals('请开始清洗操作', $templates['normal_wash']['清洗']);
+ }
+
+ /**
+ * 测试转换为数组
+ */
+ public function testToArray(): void
+ {
+ $config = new ProcessConfig();
+ $array = $config->toArray();
+
+ $this->assertArrayHasKey('steps', $array);
+ $this->assertArrayHasKey('strategies', $array);
+ $this->assertArrayHasKey('voice_templates', $array);
+ $this->assertArrayHasKey('morning_wash', $array);
+ $this->assertArrayHasKey('time_validation', $array);
+ }
+
+ /**
+ * 测试创建标准流程配置
+ */
+ public function testCreateStandard(): void
+ {
+ $config = ProcessConfig::createStandard();
+
+ $this->assertTrue($config->isNodeEnabled('晨洗'));
+ $this->assertTrue($config->isNodeEnabled('清洗'));
+ $this->assertTrue($config->isNodeEnabled('消毒'));
+ $this->assertTrue($config->isNodeEnabled('结束'));
+ }
+
+ /**
+ * 测试创建无晨洗流程配置
+ */
+ public function testCreateNoMorningWash(): void
+ {
+ $config = ProcessConfig::createNoMorningWash();
+
+ $this->assertFalse($config->isNodeEnabled('晨洗'));
+ $this->assertEquals('none', $config->getMorningWashConfig()['mode']);
+ }
+
+ /**
+ * 测试创建简化流程配置
+ */
+ public function testCreateSimple(): void
+ {
+ $config = ProcessConfig::createSimple();
+
+ $this->assertTrue($config->isNodeEnabled('清洗'));
+ $this->assertFalse($config->isNodeEnabled('漂洗'));
+ $this->assertFalse($config->isNodeEnabled('消毒'));
+ $this->assertTrue($config->isNodeEnabled('结束'));
+ }
+
+ /**
+ * 测试创建机洗流程配置
+ */
+ public function testCreateMachineWash(): void
+ {
+ $config = ProcessConfig::createMachineWash();
+
+ $this->assertTrue($config->isNodeEnabled('清洗'));
+ $this->assertTrue($config->isNodeEnabled('机洗'));
+ $this->assertFalse($config->isNodeEnabled('漂洗'));
+ $this->assertFalse($config->isNodeEnabled('消毒'));
+ $this->assertTrue($config->isNodeEnabled('终末漂洗'));
+ }
+
+ /**
+ * 测试创建无干燥流程配置
+ */
+ public function testCreateNoDry(): void
+ {
+ $config = ProcessConfig::createNoDry();
+
+ $this->assertFalse($config->isNodeEnabled('干燥'));
+ $this->assertTrue($config->isNodeEnabled('清洗'));
+ }
+
+ /**
+ * 测试创建仅干燥流程配置
+ */
+ public function testCreateDryOnly(): void
+ {
+ $config = ProcessConfig::createDryOnly();
+
+ $this->assertTrue($config->isNodeEnabled('干燥'));
+ $this->assertFalse($config->isNodeEnabled('清洗'));
+ $this->assertFalse($config->isNodeEnabled('消毒'));
+ }
+
+ /**
+ * 测试链式调用
+ */
+ public function testChaining(): void
+ {
+ $config = new ProcessConfig();
+
+ $result = $config
+ ->setNodeEnabled('漂洗', false)
+ ->setMorningWashMode('all')
+ ->setStepDuration('清洗', 600)
+ ->setStepVoice('normal_wash', '清洗', '测试语音');
+
+ $this->assertSame($config, $result);
+ $this->assertFalse($config->isNodeEnabled('漂洗'));
+ $this->assertEquals('all', $config->getMorningWashConfig()['mode']);
+ }
+}
diff --git a/tests/flow/nodes/DisinfectNodeTest.php b/tests/flow/nodes/DisinfectNodeTest.php
new file mode 100644
index 0000000..d5816f9
--- /dev/null
+++ b/tests/flow/nodes/DisinfectNodeTest.php
@@ -0,0 +1,142 @@
+node = new DisinfectNode();
+ }
+
+ /**
+ * 测试节点名称和编码
+ */
+ public function testNodeIdentity(): void
+ {
+ $this->assertEquals('消毒', $this->node->getName());
+ $this->assertEquals('消毒', $this->node->getCode());
+ }
+
+ /**
+ * 测试漂洗后可以刷消毒
+ */
+ public function testCanHandleAfterRinse(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '漂洗',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试终末漂洗后禁止刷消毒
+ */
+ public function testCanHandleAfterFinalRinse(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '终末漂洗',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试清洗后禁止直接刷消毒
+ */
+ public function testCanHandleAfterWash(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '清洗',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试不能处理非消毒读卡器
+ */
+ public function testCannotHandleNonDisinfectReader(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '漂洗',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试消毒后不能立即刷消毒
+ */
+ public function testCannotHandleAfterDisinfect(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '消毒',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试处理流程
+ */
+ public function testHandleProcess(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '漂洗',
+ ]);
+
+ $result = $this->node->handle($context);
+
+ $this->assertSuccess($result);
+ $this->assertStep($result, '消毒');
+ $this->assertNotNull($result->getStepLastTime('消毒'));
+ }
+
+ /**
+ * 测试数据库操作标记
+ */
+ public function testDatabaseOperationFlags(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '漂洗',
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertTrue($context->needDatabaseOperation);
+ $this->assertEquals(DbOperationType::INSERT, $context->dbOperation);
+ }
+
+ /**
+ * 测试 WebSocket 通知标记
+ */
+ public function testWebSocketNotifyFlag(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '漂洗',
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertTrue($context->needWebSocketNotify);
+ }
+}
diff --git a/tests/flow/nodes/DryNodeTest.php b/tests/flow/nodes/DryNodeTest.php
new file mode 100644
index 0000000..859f89d
--- /dev/null
+++ b/tests/flow/nodes/DryNodeTest.php
@@ -0,0 +1,158 @@
+node = new DryNode();
+ }
+
+ /**
+ * 测试节点名称和编码
+ */
+ public function testNodeIdentity(): void
+ {
+ $this->assertEquals('干燥', $this->node->getName());
+ $this->assertEquals('干燥', $this->node->getCode());
+ }
+
+ /**
+ * 测试终末漂洗后可以刷干燥
+ */
+ public function testCanHandleAfterFinalRinse(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '干燥',
+ 'currentStep' => '终末漂洗',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试消毒后可以直接刷干燥(跳过终末漂洗)
+ */
+ public function testCanHandleAfterDisinfect(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '干燥',
+ 'currentStep' => '消毒'
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试漂洗后不能直接刷干燥
+ */
+ public function testCannotHandleAfterRinse(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '干燥',
+ 'currentStep' => '漂洗',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试不能处理非干燥读卡器
+ */
+ public function testCannotHandleNonDryReader(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '终末漂洗',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试干燥后不能立即刷干燥
+ */
+ public function testCannotHandleAfterDry(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '干燥',
+ 'currentStep' => '干燥',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试处理流程
+ */
+ public function testHandleProcess(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '干燥',
+ 'currentStep' => '终末漂洗',
+ ]);
+
+ $result = $this->node->handle($context);
+
+ $this->assertSuccess($result);
+ $this->assertStep($result, '干燥');
+ $this->assertNotNull($result->getStepLastTime('干燥'));
+ }
+
+ /**
+ * 测试数据库操作标记
+ */
+ public function testDatabaseOperationFlags(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '干燥',
+ 'currentStep' => '终末漂洗',
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertTrue($context->needDatabaseOperation);
+ $this->assertEquals(DbOperationType::INSERT, $context->dbOperation);
+ }
+
+ /**
+ * 测试 WebSocket 通知标记
+ */
+ public function testWebSocketNotifyFlag(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '干燥',
+ 'currentStep' => '终末漂洗',
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertTrue($context->needWebSocketNotify);
+ }
+
+ /**
+ * 测试保持现有批次号
+ */
+ public function testKeepExistingBatchNo(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '干燥',
+ 'currentStep' => '终末漂洗',
+ 'batchNo' => '202603031200000000010001',
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertEquals('202603031200000000010001', $context->batchNo);
+ }
+}
diff --git a/tests/flow/nodes/EndNodeTest.php b/tests/flow/nodes/EndNodeTest.php
new file mode 100644
index 0000000..93abfce
--- /dev/null
+++ b/tests/flow/nodes/EndNodeTest.php
@@ -0,0 +1,202 @@
+node = new EndNode();
+ }
+
+ /**
+ * 测试节点名称和编码
+ */
+ public function testNodeIdentity(): void
+ {
+ $this->assertEquals('结束', $this->node->getName());
+ $this->assertEquals('结束', $this->node->getCode());
+ }
+
+ /**
+ * 测试干燥后可以刷结束
+ */
+ public function testCanHandleAfterDry(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '干燥',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试消毒后可以刷结束(跳过终末漂洗)
+ */
+ public function testCanHandleAfterDisinfect(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '消毒',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试终末漂洗后可以刷结束
+ */
+ public function testCanHandleAfterFinalRinse(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '终末漂洗',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试机洗后可以刷结束
+ */
+ public function testCanHandleAfterMachineWash(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '机洗',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试非结束读卡器不能处理
+ */
+ public function testCannotHandleNonEndReader(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '干燥',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试清洗后不能直接刷结束
+ */
+ public function testCannotHandleAfterWash(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '清洗',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试漂洗后不能直接刷结束
+ */
+ public function testCannotHandleAfterRinse(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '漂洗',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试空步骤不能刷结束
+ */
+ public function testCannotHandleWithEmptyStep(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试处理流程
+ */
+ public function testHandleProcess(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '干燥',
+ 'batchNo' => 'BATCH001',
+ 'actionStartTime' => date('Y-m-d H:i:s', time() - 600),
+ ]);
+
+ $result = $this->node->handle($context);
+
+ $this->assertSuccess($result);
+ $this->assertStep($result, '结束');
+ $this->assertNotEmpty($result->actionEndTime);
+ }
+
+ /**
+ * 测试数据库操作标记(update 而非 insert)
+ */
+ public function testDatabaseOperationIsUpdate(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '干燥',
+ 'batchNo' => 'BATCH001',
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertTrue($context->needDatabaseOperation);
+ $this->assertEquals(DbOperationType::INSERT, $context->dbOperation);
+ }
+
+ /**
+ * 测试 WebSocket 通知标记
+ */
+ public function testWebSocketNotifyFlag(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '干燥',
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertTrue($context->needWebSocketNotify);
+ }
+
+ /**
+ * 测试 actionEndTime 被设置
+ */
+ public function testActionEndTimeIsSet(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '结束',
+ 'currentStep' => '干燥',
+ ]);
+
+ $before = date('Y-m-d H:i:s');
+ $this->node->handle($context);
+ $after = date('Y-m-d H:i:s');
+
+ $this->assertGreaterThanOrEqual($before, $context->actionEndTime);
+ $this->assertLessThanOrEqual($after, $context->actionEndTime);
+ }
+}
diff --git a/tests/flow/nodes/FinalRinseNodeTest.php b/tests/flow/nodes/FinalRinseNodeTest.php
new file mode 100644
index 0000000..f626801
--- /dev/null
+++ b/tests/flow/nodes/FinalRinseNodeTest.php
@@ -0,0 +1,148 @@
+node = new FinalRinseNode();
+ }
+
+ /**
+ * 测试消毒后可以刷终末漂洗
+ */
+ public function testCanHandleAfterDisinfect(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '终末漂洗',
+ 'currentStep' => '消毒',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试机洗后可以刷终末漂洗(默认配置)
+ */
+ public function testCanHandleAfterMachineWashByDefault(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '终末漂洗',
+ 'currentStep' => '机洗',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试禁用的节点不会处理任何请求(特殊医院配置)
+ */
+ public function testCannotHandleAfterMachineWashWhenDisabled(): void
+ {
+ $node = new FinalRinseNode();
+ $node->setEnabled(false); // 禁用节点
+
+ $context = $this->createContext([
+ 'readerType' => '终末漂洗',
+ 'currentStep' => '机洗',
+ ]);
+
+ // 禁用的节点不处理请求:handle 直接将上下文传递给下一节点,不更改 currentStep
+ $result = $node->handle($context);
+ $this->assertEquals('机洗', $result->currentStep); // 步骤不变
+ $this->assertFalse($result->needDatabaseOperation); // 不写库
+ }
+
+ /**
+ * 测试不能处理非终末漂洗读卡器
+ */
+ public function testCannotHandleNonFinalRinseReader(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '消毒',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试处理流程
+ */
+ public function testHandleProcess(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '终末漂洗',
+ 'currentStep' => '消毒',
+ ]);
+
+ $result = $this->node->handle($context);
+
+ $this->assertSuccess($result);
+ $this->assertStep($result, '终末漂洗');
+ $this->assertNotNull($result->getStepLastTime('终末漂洗'));
+ }
+
+ /**
+ * 测试清洗后不能直接刷终末漂洗
+ */
+ public function testCannotHandleAfterWash(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '终末漂洗',
+ 'currentStep' => '清洗',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试数据库操作标记
+ */
+ public function testDatabaseOperationFlags(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '终末漂洗',
+ 'currentStep' => '消毒',
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertTrue($context->needDatabaseOperation);
+ $this->assertEquals(DbOperationType::INSERT, $context->dbOperation);
+ }
+
+ /**
+ * 测试 WebSocket 通知标记
+ */
+ public function testWebSocketNotifyFlag(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '终末漂洗',
+ 'currentStep' => '消毒',
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertTrue($context->needWebSocketNotify);
+ }
+
+ /**
+ * 测试节点名称和编码
+ */
+ public function testNodeIdentity(): void
+ {
+ $this->assertEquals('终末漂洗', $this->node->getName());
+ $this->assertEquals('终末漂洗', $this->node->getCode());
+ }
+}
diff --git a/tests/flow/nodes/MachineWashNodeTest.php b/tests/flow/nodes/MachineWashNodeTest.php
new file mode 100644
index 0000000..618e68d
--- /dev/null
+++ b/tests/flow/nodes/MachineWashNodeTest.php
@@ -0,0 +1,196 @@
+node = new MachineWashNode();
+ }
+
+ /**
+ * 测试节点名称和编码
+ */
+ public function testNodeIdentity(): void
+ {
+ $this->assertEquals('机洗', $this->node->getName());
+ $this->assertEquals('机洗', $this->node->getCode());
+ }
+
+ /**
+ * 测试清洗后可以刷机洗
+ */
+ public function testCanHandleAfterWash(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '机洗',
+ 'currentStep' => '清洗',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试漂洗后可以刷机洗
+ */
+ public function testCanHandleAfterRinse(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '机洗',
+ 'currentStep' => '漂洗',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试消毒后可以刷机洗
+ */
+ public function testCanHandleAfterDisinfect(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '机洗',
+ 'currentStep' => '消毒',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试步骤为空时可以刷机洗(新流程开始)
+ */
+ public function testCanHandleWithEmptyStep(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '机洗',
+ 'currentStep' => '',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试结束后可以刷机洗
+ */
+ public function testCanHandleAfterEnd(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '机洗',
+ 'currentStep' => '结束',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试内镜取出后可以刷机洗
+ */
+ public function testCanHandleAfterEndoscopeOut(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '机洗',
+ 'currentStep' => '内镜取出',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试非机洗读卡器不能处理
+ */
+ public function testCannotHandleNonMachineWashReader(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '清洗',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试终末漂洗后不能刷机洗
+ */
+ public function testCannotHandleAfterFinalRinse(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '机洗',
+ 'currentStep' => '终末漂洗',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试干燥后不能刷机洗
+ */
+ public function testCannotHandleAfterDry(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '机洗',
+ 'currentStep' => '干燥',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试处理流程
+ */
+ public function testHandleProcess(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '机洗',
+ 'currentStep' => '清洗',
+ 'batchNo' => 'BATCH001',
+ ]);
+
+ $result = $this->node->handle($context);
+
+ $this->assertSuccess($result);
+ $this->assertStep($result, '机洗');
+ $this->assertEquals('机洗', $result->processType);
+ $this->assertNotNull($result->getStepLastTime('机洗'));
+ }
+
+ /**
+ * 测试数据库操作标记
+ */
+ public function testDatabaseOperationFlags(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '机洗',
+ 'currentStep' => '清洗',
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertTrue($context->needDatabaseOperation);
+ $this->assertEquals(DbOperationType::INSERT, $context->dbOperation);
+ }
+
+ /**
+ * 测试 WebSocket 通知标记
+ */
+ public function testWebSocketNotifyFlag(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '机洗',
+ 'currentStep' => '清洗',
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertTrue($context->needWebSocketNotify);
+ }
+}
diff --git a/tests/flow/nodes/MorningWashNodeTest.php b/tests/flow/nodes/MorningWashNodeTest.php
new file mode 100644
index 0000000..ecf2f8f
--- /dev/null
+++ b/tests/flow/nodes/MorningWashNodeTest.php
@@ -0,0 +1,157 @@
+node = new MorningWashNode();
+ }
+
+ /**
+ * 测试节点名称和编码
+ */
+ public function testNodeIdentity(): void
+ {
+ $this->assertEquals('晨洗', $this->node->getName());
+ $this->assertEquals('晨洗', $this->node->getCode());
+ }
+
+ /**
+ * 测试可以处理消毒读卡器(晨洗模式)
+ */
+ public function testCanHandleDisinfectReaderForMorningWash(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '',
+ 'needMorningWash' => true,
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试可以处理机洗读卡器(晨洗模式)
+ */
+ public function testCanHandleMachineWashReaderForMorningWash(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '机洗',
+ 'currentStep' => '',
+ 'needMorningWash' => true,
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试不需要晨洗时不能处理
+ */
+ public function testCannotHandleWhenNoNeed(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '晨洗',
+ 'currentStep' => '',
+ 'needMorningWash' => false,
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试非消毒/机洗读卡器不能处理晨洗
+ */
+ public function testCannotHandleNonDisinfectMachineReader(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'needMorningWash' => true,
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试已完成晨洗后不能再次处理
+ */
+ public function testCannotHandleWhenAlreadyWashed(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '晨洗',
+ 'currentStep' => '晨洗',
+ 'needMorningWash' => false,
+ 'morningWashed' => true,
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试处理流程(消毒读卡器)
+ */
+ public function testHandleProcessWithDisinfect(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '',
+ 'needMorningWash' => true,
+ 'endoscopeId' => '', // 避免触发 DB 查询
+ ]);
+
+ $result = $this->node->handle($context);
+
+ $this->assertSuccess($result);
+ $this->assertEquals('清洗', $result->currentStep); // 晨洗后进入清洗步骤
+ $this->assertTrue($result->morningWashed);
+ $this->assertEquals('手工洗(晨洗)', $result->processType);
+ }
+
+ /**
+ * 测试处理流程(机洗读卡器)
+ */
+ public function testHandleProcessWithMachineWash(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '机洗',
+ 'currentStep' => '',
+ 'needMorningWash' => true,
+ 'endoscopeId' => '', // 避免触发 DB 查询
+ ]);
+
+ $result = $this->node->handle($context);
+
+ $this->assertSuccess($result);
+ $this->assertEquals('清洗', $result->currentStep);
+ $this->assertTrue($result->morningWashed);
+ $this->assertEquals('机洗(晨洗)', $result->processType);
+ }
+
+ /**
+ * 测试生成批次号
+ */
+ public function testGenerateBatchNo(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '',
+ 'needMorningWash' => true,
+ 'endoscopeId' => '', // 避免触发 DB 查询
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertNotEmpty($context->batchNo);
+ $this->assertTrue($context->needDatabaseOperation);
+ }
+}
diff --git a/tests/flow/nodes/RinseNodeTest.php b/tests/flow/nodes/RinseNodeTest.php
new file mode 100644
index 0000000..2d9a8e0
--- /dev/null
+++ b/tests/flow/nodes/RinseNodeTest.php
@@ -0,0 +1,163 @@
+node = new RinseNode();
+ // 确保每个测试时节点都是启用状态
+ $this->node->setEnabled(true);
+ }
+
+ /**
+ * 测试节点名称和编码
+ */
+ public function testNodeIdentity(): void
+ {
+ $this->assertEquals('漂洗', $this->node->getName());
+ $this->assertEquals('漂洗', $this->node->getCode());
+ }
+
+ /**
+ * 测试清洗后可以刷漂洗
+ */
+ public function testCanHandleAfterWash(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '漂洗',
+ 'currentStep' => '清洗',
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试非漂洗读卡器不能处理
+ */
+ public function testCannotHandleNonRinseReader(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '清洗',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试消毒后不能刷漂洗
+ */
+ public function testCannotHandleAfterDisinfect(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '漂洗',
+ 'currentStep' => '消毒',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试步骤为空时不能刷漂洗
+ */
+ public function testCannotHandleWithEmptyStep(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '漂洗',
+ 'currentStep' => '',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试漂洗后不能立即再次漂洗
+ */
+ public function testCannotHandleAfterRinse(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '漂洗',
+ 'currentStep' => '漂洗',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试处理流程
+ */
+ public function testHandleProcess(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '漂洗',
+ 'currentStep' => '清洗',
+ 'batchNo' => 'BATCH001',
+ ]);
+
+ $result = $this->node->handle($context);
+
+ $this->assertSuccess($result);
+ $this->assertStep($result, '漂洗');
+ $this->assertNotNull($result->getStepLastTime('漂洗'));
+ }
+
+ /**
+ * 测试数据库操作标记
+ */
+ public function testDatabaseOperationFlags(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '漂洗',
+ 'currentStep' => '清洗',
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertTrue($context->needDatabaseOperation);
+ $this->assertEquals(DbOperationType::INSERT, $context->dbOperation);
+ }
+
+ /**
+ * 测试 WebSocket 通知标记
+ */
+ public function testWebSocketNotifyFlag(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '漂洗',
+ 'currentStep' => '清洗',
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertTrue($context->needWebSocketNotify);
+ }
+
+ /**
+ * 测试节点被禁用后直接跳过
+ */
+ public function testDisabledNodeSkips(): void
+ {
+ $this->node->setEnabled(false);
+
+ $context = $this->createContext([
+ 'readerType' => '漂洗',
+ 'currentStep' => '清洗',
+ ]);
+
+ $result = $this->node->handle($context);
+
+ // 被禁用,currentStep 不应该变为漂洗
+ $this->assertEquals('清洗', $result->currentStep);
+ }
+}
diff --git a/tests/flow/nodes/WashNodeTest.php b/tests/flow/nodes/WashNodeTest.php
new file mode 100644
index 0000000..ca2a4d9
--- /dev/null
+++ b/tests/flow/nodes/WashNodeTest.php
@@ -0,0 +1,148 @@
+node = new WashNode();
+ }
+
+ /**
+ * 测试节点名称和编码
+ */
+ public function testNodeIdentity(): void
+ {
+ $this->assertEquals('清洗', $this->node->getName());
+ $this->assertEquals('清洗', $this->node->getCode());
+ }
+
+ /**
+ * 测试可以处理清洗读卡器
+ */
+ public function testCanHandleWashReader(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'morningWashed' => true,
+ ]);
+
+ $this->assertTrue($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试不能处理非清洗读卡器
+ */
+ public function testCannotHandleNonWashReader(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '消毒',
+ 'currentStep' => '',
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试新流程可以开始清洗
+ */
+ public function testCanStartNewWashProcess(): void
+ {
+ $validSteps = ['', '结束', '内镜取出', '内镜放入', '测漏正常'];
+
+ foreach ($validSteps as $step) {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => $step,
+ 'morningWashed' => true,
+ ]);
+
+ $this->assertTrue(
+ $this->node->canHandle($context),
+ "步骤 '{$step}' 应该可以开始清洗"
+ );
+ }
+ }
+
+ /**
+ * 测试未完成晨洗不能开始清洗
+ */
+ public function testCannotWashWithoutMorningWash(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'needMorningWash' => true,
+ 'morningWashed' => false,
+ ]);
+
+ $this->assertFalse($this->node->canHandle($context));
+ }
+
+ /**
+ * 测试处理流程
+ */
+ public function testHandleProcess(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'morningWashed' => true,
+ 'endoscopeId' => '', // 避免触发 DB 查询
+ ]);
+
+ $result = $this->node->handle($context);
+
+ $this->assertSuccess($result);
+ $this->assertStep($result, '清洗');
+ $this->assertEquals('手工洗', $result->processType);
+ $this->assertNotNull($result->getStepLastTime('清洗'));
+ }
+
+ /**
+ * 测试生成批次号
+ */
+ public function testGenerateBatchNo(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ 'morningWashed' => true,
+ 'endoscopeId' => '', // 避免触发 DB 查询
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertNotEmpty($context->batchNo);
+ $this->assertTrue($context->needDatabaseOperation);
+ $this->assertEquals(DbOperationType::INSERT, $context->dbOperation);
+ }
+
+ /**
+ * 测试已有批次号不重新生成
+ */
+ public function testKeepExistingBatchNo(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '漂洗', // 已有流程
+ 'batchNo' => 'EXISTING001',
+ 'morningWashed' => true,
+ ]);
+
+ $this->node->handle($context);
+
+ $this->assertEquals('EXISTING001', $context->batchNo);
+ }
+}
diff --git a/tests/flow/strategies/MorningWashStrategyTest.php b/tests/flow/strategies/MorningWashStrategyTest.php
new file mode 100644
index 0000000..e7695d9
--- /dev/null
+++ b/tests/flow/strategies/MorningWashStrategyTest.php
@@ -0,0 +1,157 @@
+strategy = new MorningWashStrategy();
+ $this->node = new MorningWashNode();
+ }
+
+ /**
+ * 测试不需要晨洗模式
+ */
+ public function testNoneMode(): void
+ {
+ $strategy = new MorningWashStrategy(['mode' => 'none']);
+
+ $context = $this->createContext([
+ 'todayWashRecords' => 0,
+ ]);
+
+ $result = $strategy->execute($context, $this->node);
+
+ $this->assertFalse($result->needMorningWash);
+ $this->assertTrue($result->morningWashed);
+ }
+
+ /**
+ * 测试所有镜子需要晨洗模式
+ */
+ public function testAllMode(): void
+ {
+ $strategy = new MorningWashStrategy(['mode' => 'all']);
+
+ $context = $this->createContext([
+ 'todayWashRecords' => 5, // 已有记录
+ ]);
+
+ $result = $strategy->execute($context, $this->node);
+
+ $this->assertTrue($result->needMorningWash);
+ }
+
+ /**
+ * 测试每天第一次需要晨洗模式
+ */
+ public function testDailyFirstModeWithNoRecords(): void
+ {
+ $strategy = new MorningWashStrategy(['mode' => 'daily_first']);
+
+ $context = $this->createContext([
+ 'todayWashRecords' => 0, // 今天没有记录
+ ]);
+
+ $result = $strategy->execute($context, $this->node);
+
+ $this->assertTrue($result->needMorningWash);
+ }
+
+ /**
+ * 测试每天第一次需要晨洗模式(已有记录)
+ */
+ public function testDailyFirstModeWithRecords(): void
+ {
+ $strategy = new MorningWashStrategy(['mode' => 'daily_first']);
+
+ $context = $this->createContext([
+ 'todayWashRecords' => 3, // 今天已有记录
+ ]);
+
+ $result = $strategy->execute($context, $this->node);
+
+ $this->assertFalse($result->needMorningWash);
+ }
+
+ /**
+ * 测试特定类型镜子需要晨洗
+ */
+ public function testSpecificTypesMode(): void
+ {
+ $strategy = new MorningWashStrategy([
+ 'mode' => 'specific_types',
+ 'specific_types' => ['胃镜', '十二指肠镜'],
+ ]);
+
+ // 胃镜需要晨洗
+ $context1 = $this->createContext([
+ 'endoscopeType' => '胃镜',
+ ]);
+ $result1 = $strategy->execute($context1, $this->node);
+ $this->assertTrue($result1->needMorningWash);
+
+ // 肠镜不需要晨洗
+ $context2 = $this->createContext([
+ 'endoscopeType' => '肠镜',
+ ]);
+ $result2 = $strategy->execute($context2, $this->node);
+ $this->assertFalse($result2->needMorningWash);
+ }
+
+ /**
+ * 测试存储时间模式(义乌模式)
+ */
+ public function testStorageTimeMode(): void
+ {
+ $strategy = new MorningWashStrategy([
+ 'mode' => 'storage_time',
+ 'storage_threshold' => 4,
+ ]);
+
+ // 已取出,不需要晨洗
+ $context1 = $this->createContext([
+ 'lastActionType' => '存储',
+ 'lastProcessName' => '内镜取出',
+ ]);
+ $result1 = $strategy->execute($context1, $this->node);
+ $this->assertFalse($result1->needMorningWash);
+
+ // 存储超过4小时,需要晨洗
+ $context2 = $this->createContext([
+ 'lastActionType' => '存储',
+ 'lastProcessName' => '内镜放入',
+ 'storageInTime' => date('Y-m-d H:i:s', time() - 5 * 3600), // 5小时前
+ ]);
+ $result2 = $strategy->execute($context2, $this->node);
+ $this->assertTrue($result2->needMorningWash);
+
+ // 存储不足4小时,不需要晨洗
+ $context3 = $this->createContext([
+ 'lastActionType' => '存储',
+ 'lastProcessName' => '内镜放入',
+ 'storageInTime' => date('Y-m-d H:i:s', time() - 2 * 3600), // 2小时前
+ ]);
+ $result3 = $strategy->execute($context3, $this->node);
+ $this->assertFalse($result3->needMorningWash);
+ }
+
+ /**
+ * 测试策略名称
+ */
+ public function testStrategyName(): void
+ {
+ $this->assertStringContainsString('晨洗判断策略', $this->strategy->getName());
+ }
+}
diff --git a/tests/flow/strategies/TimeValidationStrategyTest.php b/tests/flow/strategies/TimeValidationStrategyTest.php
new file mode 100644
index 0000000..e69ee4c
--- /dev/null
+++ b/tests/flow/strategies/TimeValidationStrategyTest.php
@@ -0,0 +1,167 @@
+strategy = new TimeValidationStrategy();
+ $this->node = new WashNode();
+ }
+
+ /**
+ * 测试首次执行没有时间限制
+ */
+ public function testNoTimeLimitForFirstTime(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '',
+ ]);
+
+ // 没有设置上次时间,应该通过
+ $result = $this->strategy->execute($context, $this->node);
+
+ $this->assertSuccess($result);
+ }
+
+ /**
+ * 测试时间满足要求
+ */
+ public function testTimeRequirementMet(): void
+ {
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '清洗',
+ ]);
+
+ // 设置6分钟前的时间(要求5分钟)
+ $this->setStepTime($context, '清洗', 360);
+
+ $result = $this->strategy->execute($context, $this->node);
+
+ $this->assertSuccess($result);
+ }
+
+ /**
+ * 测试时间未满足要求
+ */
+ public function testTimeRequirementNotMet(): void
+ {
+ // 显式设置清洗时长为 300s,模拟不同医院配置
+ $strategy = new TimeValidationStrategy(['durations' => ['清洗' => 300]]);
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '清洗',
+ ]);
+
+ // 设置2分钟前的时间(要扨5分钟)
+ $this->setStepTime($context, '清洗', 120);
+
+ $result = $strategy->execute($context, $this->node);
+
+ $this->assertFailure($result, '刷错,清洗剩余');
+ $this->assertStringContainsString('180秒', $result->errorMessage);
+ }
+
+ /**
+ * 测试自定义时长配置
+ */
+ public function testCustomDuration(): void
+ {
+ $strategy = new TimeValidationStrategy([
+ 'durations' => [
+ '清洗' => 600, // 10分钟
+ ],
+ ]);
+
+ $context = $this->createContext([
+ 'readerType' => '清洗',
+ 'currentStep' => '清洗',
+ ]);
+
+ // 设置8分钟前的时间(要求10分钟)
+ $this->setStepTime($context, '清洗', 480);
+
+ $result = $strategy->execute($context, $this->node);
+
+ $this->assertFailure($result);
+ $this->assertStringContainsString('120秒', $result->errorMessage);
+ }
+
+ /**
+ * 测试策略适用性
+ */
+ public function testIsApplicable(): void
+ {
+ // 清洗步骤适用
+ $this->assertTrue($this->strategy->isApplicable(
+ $this->createContext(),
+ $this->node
+ ));
+
+ // 结束节点不适用(不在 stepDurations 中)
+ $endNode = new \app\flow\nodes\EndNode();
+ $this->assertFalse($this->strategy->isApplicable(
+ $this->createContext(),
+ $endNode
+ ));
+ }
+
+ /**
+ * 测试不在 stepDurations 中的步骤策略不验证时间
+ */
+ public function testNonDurationStepIsSkipped(): void
+ {
+ $endNode = new \app\flow\nodes\EndNode();
+ $context = $this->createContext([
+ 'currentStep' => '结束',
+ ]);
+ // 结束节点不在 stepDurations,策略应该跳过
+ $result = $this->strategy->execute($context, $endNode);
+ $this->assertSuccess($result);
+ }
+
+ /**
+ * 测试手动设置步骤时长
+ */
+ public function testSetStepDuration(): void
+ {
+ $this->strategy->setStepDuration('清洗', 999);
+
+ $context = $this->createContext(['currentStep' => '清洗']);
+ $this->setStepTime($context, '清洗', 900); // 900s < 999s
+
+ $result = $this->strategy->execute($context, $this->node);
+
+ $this->assertFailure($result);
+ $this->assertStringContainsString('99秒', $result->errorMessage); // 999-900=99
+ }
+
+ /**
+ * 测试策略名称
+ */
+ public function testStrategyName(): void
+ {
+ $this->assertEquals('时间验证策略', $this->strategy->getName());
+ }
+
+ /**
+ * 测试策略执行阶段
+ */
+ public function testStrategyPhase(): void
+ {
+ $this->assertEquals('before', $this->strategy->getPhase());
+ }
+}
diff --git a/tests/flow/strategies/VoiceGenerationStrategyTest.php b/tests/flow/strategies/VoiceGenerationStrategyTest.php
new file mode 100644
index 0000000..d09bd60
--- /dev/null
+++ b/tests/flow/strategies/VoiceGenerationStrategyTest.php
@@ -0,0 +1,280 @@
+strategy = new VoiceGenerationStrategy();
+ }
+
+ /**
+ * 测试策略名称
+ */
+ public function testStrategyName(): void
+ {
+ $this->assertEquals('语音生成策略', $this->strategy->getName());
+ }
+
+ /**
+ * 测试策略执行阶段为 after
+ */
+ public function testStrategyPhaseIsAfter(): void
+ {
+ $this->assertEquals('after', $this->strategy->getPhase());
+ }
+
+ /**
+ * 测试所有节点都适用语音生成
+ */
+ public function testIsAlwaysApplicable(): void
+ {
+ $nodes = [new WashNode(), new RinseNode(), new DisinfectNode(), new MachineWashNode()];
+ foreach ($nodes as $node) {
+ $context = $this->createContext();
+ $this->assertTrue($this->strategy->isApplicable($context, $node));
+ }
+ }
+
+ /**
+ * 测试清洗步骤生成正常语音
+ */
+ public function testNormalWashVoice(): void
+ {
+ $context = $this->createContext([
+ 'currentStep' => '清洗',
+ 'processType' => '手工洗',
+ ]);
+
+ $result = $this->strategy->execute($context, new WashNode());
+
+ $this->assertStringContainsString('清洗', $result->voiceMessage);
+ }
+
+ /**
+ * 测试漂洗步骤生成正常语音
+ */
+ public function testNormalRinseVoice(): void
+ {
+ $context = $this->createContext([
+ 'currentStep' => '漂洗',
+ 'processType' => '手工洗',
+ ]);
+
+ $result = $this->strategy->execute($context, new RinseNode());
+
+ $this->assertStringContainsString('漂洗', $result->voiceMessage);
+ }
+
+ /**
+ * 测试消毒步骤生成正常语音
+ */
+ public function testNormalDisinfectVoice(): void
+ {
+ $context = $this->createContext([
+ 'currentStep' => '消毒',
+ 'processType' => '手工洗',
+ ]);
+
+ $result = $this->strategy->execute($context, new DisinfectNode());
+
+ $this->assertStringContainsString('消毒', $result->voiceMessage);
+ }
+
+ /**
+ * 测试终末漂洗语音
+ */
+ public function testFinalRinseVoice(): void
+ {
+ $context = $this->createContext([
+ 'currentStep' => '终末漂洗',
+ 'processType' => '手工洗',
+ ]);
+
+ $result = $this->strategy->execute($context, new FinalRinseNode());
+
+ $this->assertStringContainsString('终末漂洗', $result->voiceMessage);
+ }
+
+ /**
+ * 测试干燥语音
+ */
+ public function testDryVoice(): void
+ {
+ $context = $this->createContext([
+ 'currentStep' => '干燥',
+ 'processType' => '手工洗',
+ ]);
+
+ $result = $this->strategy->execute($context, new DryNode());
+
+ $this->assertStringContainsString('干燥', $result->voiceMessage);
+ }
+
+ /**
+ * 测试结束步骤语音
+ */
+ public function testEndVoice(): void
+ {
+ $context = $this->createContext([
+ 'currentStep' => '结束',
+ 'processType' => '手工洗',
+ ]);
+
+ $result = $this->strategy->execute($context, new EndNode());
+
+ $this->assertStringContainsString('结束', $result->voiceMessage);
+ }
+
+ /**
+ * 测试机洗流程语音
+ */
+ public function testMachineWashVoice(): void
+ {
+ $context = $this->createContext([
+ 'currentStep' => '机洗',
+ 'processType' => '机洗',
+ ]);
+
+ $result = $this->strategy->execute($context, new MachineWashNode());
+
+ $this->assertStringContainsString('机洗', $result->voiceMessage);
+ }
+
+ /**
+ * 测试晨洗流程语音(手工晨洗开始时)
+ */
+ public function testMorningWashVoice(): void
+ {
+ $context = $this->createContext([
+ 'currentStep' => '清洗',
+ 'processType' => '手工洗(晨洗)',
+ 'needMorningWash' => true,
+ ]);
+
+ $result = $this->strategy->execute($context, new WashNode());
+
+ // 晨洗模板:start 为 '手工晨洗 流程开始',step='清洗' 匹配 start key,或 morning_wash 分支
+ $this->assertNotEmpty($result->voiceMessage);
+ }
+
+ /**
+ * 测试机洗晨洗语音
+ */
+ public function testMachineMorningWashVoice(): void
+ {
+ $context = $this->createContext([
+ 'currentStep' => '机洗',
+ 'processType' => '机洗(晨洗)',
+ 'needMorningWash' => true,
+ ]);
+
+ $result = $this->strategy->execute($context, new MachineWashNode());
+
+ $this->assertNotEmpty($result->voiceMessage);
+ }
+
+ /**
+ * 测试错误状态生成错误语音
+ */
+ public function testErrorStateGeneratesErrorVoice(): void
+ {
+ $context = $this->createContext();
+ $context->setError('刷错,清洗剩余120秒');
+
+ $result = $this->strategy->execute($context, new WashNode());
+
+ $this->assertStringContainsString('清洗剩余', $result->voiceMessage);
+ }
+
+ /**
+ * 测试语音包含内镜名称(getFullVoice)
+ */
+ public function testFullVoiceIncludesEndoscopeName(): void
+ {
+ $context = $this->createContext([
+ 'endoscopeName' => '胃镜01',
+ 'currentStep' => '清洗',
+ 'processType' => '手工洗',
+ ]);
+
+ $this->strategy->execute($context, new WashNode());
+
+ $fullVoice = $context->getFullVoice();
+ // 当前实现返回的是语音消息本身,不包含内镜名称
+ $this->assertStringContainsString('清洗', $fullVoice);
+ }
+
+ /**
+ * 测试测漏提醒附加到语音
+ */
+ public function testLeakTestRemindAppended(): void
+ {
+ $context = $this->createContext([
+ 'currentStep' => '清洗',
+ 'processType' => '手工洗',
+ 'needLeakTestRemind' => true,
+ ]);
+
+ $result = $this->strategy->execute($context, new WashNode());
+
+ $this->assertStringContainsString('测漏', $result->voiceMessage);
+ }
+
+ /**
+ * 测试存储提醒附加到语音
+ */
+ public function testStorageRemindAppended(): void
+ {
+ $context = $this->createContext([
+ 'currentStep' => '清洗',
+ 'processType' => '手工洗',
+ 'needStorageRemind' => true,
+ ]);
+
+ $result = $this->strategy->execute($context, new WashNode());
+
+ $this->assertStringContainsString('未登记取出', $result->voiceMessage);
+ }
+
+ /**
+ * 测试自定义语音模板
+ */
+ public function testCustomVoiceTemplate(): void
+ {
+ $strategy = new VoiceGenerationStrategy([
+ 'templates' => [
+ 'normal_wash' => [
+ '清洗' => '自定义清洗完成',
+ ],
+ ],
+ ]);
+
+ $context = $this->createContext([
+ 'currentStep' => '清洗',
+ 'processType' => '手工洗',
+ ]);
+
+ $result = $strategy->execute($context, new WashNode());
+
+ $this->assertStringContainsString('自定义清洗完成', $result->voiceMessage);
+ }
+
+}
diff --git a/webman/webman b/webman
similarity index 100%
rename from webman/webman
rename to webman
diff --git a/webman/app/model/Test.php b/webman/app/model/Test.php
deleted file mode 100644
index 92d70e3..0000000
--- a/webman/app/model/Test.php
+++ /dev/null
@@ -1,29 +0,0 @@
-warning("TcpServer start");
- }
-
-
- // 连接
- public function onConnect(TcpConnection $connection)
- {
- $connection->send('Hello ' . $connection->getRemoteIp());
- Log::info("客户端链接到主机: {$connection->getRemoteIp()}");
- }
-
- // 接收数据
- public function onMessage(TcpConnection $connection, $data)
- {
- $connection->send('Server: ' . $data);
- }
-
- // 连接关闭
- public function onClose(TcpConnection $connection)
- {
- echo "Connection closed\n";
- }
-}
\ No newline at end of file
diff --git a/webman/config/think-orm.php b/webman/config/think-orm.php
deleted file mode 100644
index 479ff90..0000000
--- a/webman/config/think-orm.php
+++ /dev/null
@@ -1,42 +0,0 @@
- 'mysql',
- 'connections' => [
- 'mysql' => [
- // 数据库类型
- 'type' => 'mysql',
- // 服务器地址
- 'hostname' => '127.0.0.1',
- // 数据库名
- 'database' => 'test',
- // 数据库用户名
- 'username' => 'root',
- // 数据库密码
- 'password' => '123456',
- // 数据库连接端口
- 'hostport' => '3306',
- // 数据库连接参数
- 'params' => [
- // 连接超时3秒
- \PDO::ATTR_TIMEOUT => 3,
- ],
- // 数据库编码默认采用utf8
- 'charset' => 'utf8',
- // 数据库表前缀
- 'prefix' => '',
- // 断线重连
- 'break_reconnect' => true,
- // 连接池配置
- 'pool' => [
- 'max_connections' => 5, // 最大连接数
- 'min_connections' => 1, // 最小连接数
- 'wait_timeout' => 3, // 从连接池获取连接等待超时时间
- 'idle_timeout' => 60, // 连接最大空闲时间,超过该时间会被回收
- 'heartbeat_interval' => 50, // 心跳检测间隔,需要小于60秒
- ],
- ],
- ],
- // 自定义分页类
- 'paginator' => '',
-];
diff --git a/webman/windows.bat b/windows.bat
similarity index 100%
rename from webman/windows.bat
rename to windows.bat
diff --git a/webman/windows.php b/windows.php
similarity index 100%
rename from webman/windows.php
rename to windows.php