From ee348b49250ca61fc3c1ddca52d4e1e653b5033e Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Mon, 5 Jan 2026 14:33:21 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 14 + LICENSE | 21 + README.md | 70 + app/bootstrap/Audit.php | 31 + app/command/CrmRotateUserCommand.php | 105 ++ app/command/CustomerCodeCommand.php | 62 + app/command/CustomerCreatedAtCommand.php | 69 + app/command/DomainCommand.php | 79 ++ app/command/EmailDetailCommand.php | 206 +++ app/command/EmailParseCommand.php | 144 +++ app/command/EmailUidCommand.php | 185 +++ app/command/FinanceRecoCommand.php | 32 + app/command/FixInquiryLogOrderNo.php | 103 ++ app/command/ImportCustomerCommand.php | 178 +++ app/command/InitUserPasswordCommand.php | 34 + app/command/TbCommand.php | 36 + ...UpdateInquiryLogFromCrmCustomerCommand.php | 144 +++ app/command/VehMoneyCommand.php | 29 + app/common/audit/AuditBuffer.php | 77 ++ app/common/controller/AuthController.php | 121 ++ app/common/controller/BaseController.php | 92 ++ app/common/controller/BrandController.php | 90 ++ .../controller/BrandSeriesController.php | 121 ++ .../controller/FinPaymentApplyController.php | 181 +++ .../controller/FinReceiptController.php | 195 +++ .../controller/FinReceiptHisController.php | 98 ++ .../InvRequisitionApproveController.php | 33 + .../InvRequisitionAttachmentController.php | 33 + .../InvRequisitionItemController.php | 33 + ...uisitionItemReturnAttachmentController.php | 33 + .../InvRequisitionItemReturnController.php | 33 + .../MessageNotificationsController.php | 52 + .../MessageUserStatusController.php | 95 ++ app/common/controller/NotifyController.php | 136 ++ app/common/controller/PresetController.php | 233 ++++ .../PurchaseOrderItemsController.php | 33 + .../controller/PurchaseOrdersController.php | 137 ++ app/common/controller/ResourceController.php | 82 ++ .../controller/StockOrdersFilesController.php | 33 + app/common/controller/SysOpLogController.php | 33 + app/common/controller/SysPortController.php | 53 + .../controller/SysUpdateLogController.php | 35 + app/common/controller/UploadController.php | 302 +++++ .../UsedcarCrawlResultsController.php | 33 + .../UsedcarCrawlResultsImagesController.php | 33 + app/common/controller/UserController.php | 128 ++ app/common/controller/VehColorController.php | 56 + .../controller/VehDictI18nController.php | 33 + .../controller/VehDictMappingController.php | 33 + .../controller/VehModificationController.php | 47 + .../controller/VehMoneyValueController.php | 33 + .../VehVehicleListingController.php | 33 + .../controller/VehVehicleSkuController.php | 223 ++++ .../controller/VehVehicleUnitController.php | 33 + app/common/logic/ApprovalFlowsLogic.php | 17 + app/common/logic/ApprovalInstancesLogic.php | 17 + app/common/logic/ApprovalRecordsLogic.php | 17 + app/common/logic/AuthLogic.php | 597 +++++++++ app/common/logic/BrandLogic.php | 22 + app/common/logic/BrandSeriesLogic.php | 22 + app/common/logic/CorpFeishuAppLogic.php | 23 + app/common/logic/CorpLogic.php | 21 + app/common/logic/CountryLogic.php | 17 + app/common/logic/FinPaymentApplyLogic.php | 17 + app/common/logic/FinReceiptHisLogic.php | 17 + app/common/logic/FinReceiptLogic.php | 21 + .../logic/FinanceReconDubaiFilesLogic.php | 17 + app/common/logic/Fingerprint.php | 262 ++++ .../logic/InquiryTransmitConfigLogic.php | 17 + app/common/logic/InquiryTransmitLogic.php | 17 + app/common/logic/InvItemLogic.php | 22 + .../logic/InvRequisitionApproveLogic.php | 17 + .../logic/InvRequisitionAttachmentLogic.php | 17 + app/common/logic/InvRequisitionItemLogic.php | 17 + ...nvRequisitionItemReturnAttachmentLogic.php | 17 + .../logic/InvRequisitionItemReturnLogic.php | 17 + app/common/logic/InvRequisitionLogic.php | 17 + .../logic/MessageNotificationsLogic.php | 29 + app/common/logic/MessageUserStatusLogic.php | 17 + app/common/logic/ModelNaming.php | 101 ++ app/common/logic/PermLogic.php | 144 +++ app/common/logic/PurchaseOrderItemsLogic.php | 17 + app/common/logic/PurchaseOrdersLogic.php | 52 + app/common/logic/ResourceLogic.php | 37 + app/common/logic/RoleLogic.php | 85 ++ app/common/logic/SkuCodeBuilder.php | 233 ++++ app/common/logic/StockOrderAccountsLogic.php | 17 + .../logic/StockOrderItemOptionsLogic.php | 17 + app/common/logic/StockOrderItemsLogic.php | 17 + app/common/logic/StockOrdersFilesLogic.php | 17 + app/common/logic/StockOrdersLogic.php | 17 + app/common/logic/SysAccountLogic.php | 22 + app/common/logic/SysConfigLogic.php | 22 + app/common/logic/SysCurrencyLogic.php | 21 + app/common/logic/SysDictItemLogic.php | 22 + app/common/logic/SysDictLogic.php | 27 + app/common/logic/SysOpLogLogic.php | 17 + app/common/logic/SysPortLogic.php | 22 + app/common/logic/SysUpdateLogLogic.php | 17 + app/common/logic/SystemsLogic.php | 21 + app/common/logic/UploadedFilesLogic.php | 169 +++ .../logic/UsedcarCrawlResultsImagesLogic.php | 17 + app/common/logic/UsedcarCrawlResultsLogic.php | 17 + app/common/logic/UserGroupLogic.php | 79 ++ app/common/logic/UserGroupMembersLogic.php | 22 + app/common/logic/UserLogic.php | 110 ++ app/common/logic/VehColorLogic.php | 22 + app/common/logic/VehDictI18nLogic.php | 17 + app/common/logic/VehDictMappingLogic.php | 17 + app/common/logic/VehModificationLogic.php | 22 + app/common/logic/VehMoneyValueLogic.php | 17 + app/common/logic/VehVehicleListingLogic.php | 17 + app/common/logic/VehVehicleSkuLogic.php | 60 + app/common/logic/VehVehicleUnitLogic.php | 17 + app/common/model/ApprovalCcs.php | 49 + app/common/model/ApprovalFlows.php | 53 + app/common/model/ApprovalInstances.php | 122 ++ app/common/model/ApprovalRecords.php | 79 ++ app/common/model/Brand.php | 49 + app/common/model/BrandSeries.php | 49 + app/common/model/Corp.php | 59 + app/common/model/CorpFeishuApp.php | 60 + app/common/model/Country.php | 49 + app/common/model/CrmCustomer.php | 76 ++ app/common/model/CustomerAudit.php | 49 + app/common/model/EmailParsedResult.php | 49 + app/common/model/FinPaymentApply.php | 76 ++ app/common/model/FinPaymentStock.php | 67 + app/common/model/FinReceipt.php | 81 ++ app/common/model/FinReceiptHis.php | 49 + app/common/model/FinanceReconDubaiFiles.php | 49 + app/common/model/InquiryChannel.php | 49 + app/common/model/InquiryLog.php | 81 ++ app/common/model/InquiryTransmit.php | 49 + app/common/model/InquiryTransmitConfig.php | 49 + app/common/model/InvItem.php | 52 + app/common/model/InvRequisition.php | 58 + app/common/model/InvRequisitionApprove.php | 49 + app/common/model/InvRequisitionAttachment.php | 49 + app/common/model/InvRequisitionItem.php | 49 + app/common/model/InvRequisitionItemReturn.php | 49 + .../InvRequisitionItemReturnAttachment.php | 49 + app/common/model/MessageNotifications.php | 98 ++ app/common/model/MessageUserStatus.php | 66 + app/common/model/PurchaseOrderItems.php | 71 ++ app/common/model/PurchaseOrders.php | 64 + app/common/model/Resource.php | 49 + app/common/model/Role.php | 66 + app/common/model/SalesAnnualTargets.php | 49 + app/common/model/SalesMonthlyTargets.php | 49 + app/common/model/StockOrderAccounts.php | 58 + app/common/model/StockOrderItemOptions.php | 58 + app/common/model/StockOrderItems.php | 99 ++ app/common/model/StockOrders.php | 126 ++ app/common/model/StockOrdersFiles.php | 60 + app/common/model/SysAccount.php | 49 + app/common/model/SysConfig.php | 49 + app/common/model/SysCurrency.php | 49 + app/common/model/SysCurrencyTranslation.php | 49 + app/common/model/SysDict.php | 93 ++ app/common/model/SysDictCategory.php | 58 + app/common/model/SysDictItem.php | 67 + app/common/model/SysDictItemTranslation.php | 49 + app/common/model/SysDictTranslation.php | 49 + app/common/model/SysOpLog.php | 49 + app/common/model/SysPort.php | 49 + app/common/model/SysPortTranslation.php | 49 + app/common/model/SysUpdateLog.php | 49 + app/common/model/Systems.php | 49 + app/common/model/UploadedFiles.php | 63 + app/common/model/UsedcarCrawlResults.php | 49 + .../model/UsedcarCrawlResultsImages.php | 49 + app/common/model/User.php | 107 ++ app/common/model/UserGroup.php | 56 + app/common/model/UserGroupMembers.php | 60 + app/common/model/UserRoles.php | 47 + app/common/model/UserSn.php | 46 + app/common/model/UserSystems.php | 53 + app/common/model/UserTokenLog.php | 59 + app/common/model/VehColor.php | 49 + app/common/model/VehColorTranslation.php | 49 + app/common/model/VehDictI18n.php | 49 + app/common/model/VehDictMapping.php | 52 + app/common/model/VehModification.php | 49 + app/common/model/VehMoneyValue.php | 87 ++ app/common/model/VehMoneyValue_bak.php | 49 + app/common/model/VehVehicleListing.php | 49 + app/common/model/VehVehicleSku.php | 81 ++ app/common/model/VehVehicleUnit.php | 49 + app/common/service/ApprovalFlowsService.php | 62 + .../service/ApprovalInstancesService.php | 31 + app/common/service/ApprovalRecordsService.php | 194 +++ app/common/service/AuthService.php | 89 ++ app/common/service/BasicDataService.php | 134 ++ app/common/service/BrandSeriesService.php | 34 + app/common/service/BrandService.php | 24 + app/common/service/CorpFeishuAppService.php | 21 + app/common/service/CorpService.php | 24 + app/common/service/CrawlService.php | 90 ++ app/common/service/FinPaymentApplyService.php | 293 +++++ app/common/service/FinReceiptHisService.php | 62 + app/common/service/FinReceiptService.php | 335 +++++ .../service/FinanceReconDubaiFilesService.php | 290 +++++ .../service/InquiryTransmitConfigService.php | 67 + app/common/service/InquiryTransmitService.php | 92 ++ app/common/service/InvItemService.php | 199 +++ .../service/InvRequisitionApproveService.php | 31 + .../InvRequisitionAttachmentService.php | 31 + ...RequisitionItemReturnAttachmentService.php | 31 + .../InvRequisitionItemReturnService.php | 449 +++++++ .../service/InvRequisitionItemService.php | 31 + app/common/service/InvRequisitionService.php | 212 +++ app/common/service/MenuBadgeService.php | 178 +++ .../service/MessageNotificationsService.php | 286 +++++ .../service/MessageUserStatusService.php | 136 ++ app/common/service/OssService.php | 113 ++ app/common/service/PermService.php | 65 + app/common/service/PermService_bak.php | 109 ++ .../service/PurchaseOrderItemsService.php | 31 + app/common/service/PurchaseOrdersService.php | 357 ++++++ app/common/service/ResourceService.php | 130 ++ .../service/StockOrderAccountsService.php | 31 + .../service/StockOrderItemOptionsService.php | 31 + app/common/service/StockOrderItemsService.php | 31 + .../service/StockOrdersFilesService.php | 31 + app/common/service/StockOrdersService.php | 1132 +++++++++++++++++ app/common/service/SysAccountService.php | 28 + app/common/service/SysConfigService.php | 37 + app/common/service/SysCurrencyService.php | 25 + app/common/service/SysDictItemService.php | 42 + app/common/service/SysDictService.php | 52 + app/common/service/SysOpLogService.php | 78 ++ app/common/service/SysPortService.php | 34 + app/common/service/SysUpdateLogService.php | 77 ++ app/common/service/SystemsService.php | 24 + app/common/service/UploadService.php | 177 +++ app/common/service/UploadedFilesService.php | 238 ++++ .../UsedcarCrawlResultsImagesService.php | 31 + .../service/UsedcarCrawlResultsService.php | 31 + .../service/UserGroupMembersService.php | 33 + app/common/service/UserGroupService.php | 124 ++ app/common/service/UserService.php | 253 ++++ app/common/service/VehColorService.php | 33 + app/common/service/VehDictI18nService.php | 75 ++ app/common/service/VehDictMappingService.php | 57 + app/common/service/VehModificationService.php | 28 + app/common/service/VehMoneyValueService.php | 77 ++ .../service/VehVehicleListingService.php | 31 + app/common/service/VehVehicleSkuService.php | 250 ++++ app/common/service/VehVehicleUnitService.php | 31 + app/common/traits/Auditable.php | 48 + app/common/traits/DataPermission.php | 425 +++++++ app/common/traits/DataTransformer.php | 152 +++ app/common/traits/FormatKvTrait.php | 154 +++ app/common/traits/HasMoneyJournal.php | 85 ++ app/common/traits/RequestTrait.php | 38 + app/common/traits/ResponseTrait.php | 77 ++ .../traits/crud/CrudControllerTrait.php | 148 +++ app/common/traits/crud/CrudLogicTrait.php | 80 ++ app/common/traits/crud/CrudServiceTrait.php | 202 +++ .../traits/crud/CrudServiceTrait_bak.php | 194 +++ app/common/utils/CrudUtil.php | 808 ++++++++++++ app/common/utils/CrudUtil_bak.php | 843 ++++++++++++ app/common/utils/Feishu.php | 229 ++++ app/common/utils/FeishuMessage.php | 565 ++++++++ app/common/utils/Jwt.php | 108 ++ app/common/utils/MigrationResources.php | 194 +++ app/common/utils/OauthClients.php | 27 + app/common/utils/RedisUidRotator.php | 161 +++ app/common/utils/TableInspector.php | 57 + app/common/utils/Tree.php | 505 ++++++++ app/common/utils/UidRotator.php | 178 +++ app/common/utils/WsEvents.php | 360 ++++++ app/common/utils/WsResponse.php | 47 + .../utils/crawl/service/UsedCarCrawl.php | 857 +++++++++++++ .../utils/crawl/service/UsedCarCrawl_bak.php | 681 ++++++++++ .../utils/crawl/service/UsedCarCrawl_bak2.php | 798 ++++++++++++ app/common/utils/email/Emails.php | 564 ++++++++ app/common/utils/email/MailSend.php | 69 + app/common/utils/excel/ExcelImportTrait.php | 133 ++ app/common/utils/excel/ExcelReader.php | 292 +++++ .../utils/pdf/controller/PdfController.php | 76 ++ app/common/utils/pdf/service/MpdfDriver.php | 118 ++ app/common/utils/pdf/service/PdfHelper.php | 80 ++ app/common/utils/pdf/service/PdfStorage.php | 38 + .../utils/pdf/template/stockOrder copy 2.stub | 336 +++++ .../utils/pdf/template/stockOrder copy.stub | 326 +++++ app/common/utils/pdf/template/stockOrder.stub | 379 ++++++ app/common/utils/scaffold/Generate.php | 194 +++ .../scaffold/templates/ControllerStub.stub | 33 + .../utils/scaffold/templates/Email.stub | 365 ++++++ .../utils/scaffold/templates/LogicStub.stub | 17 + .../utils/scaffold/templates/ModelStub.stub | 49 + .../utils/scaffold/templates/ServiceStub.stub | 31 + .../scaffold/templates/ValidateStub.stub | 29 + .../scaffold/templates/paymentFiles.stub | 103 ++ .../utils/scaffold/templates/workOrder.stub | 103 ++ app/common/utils/translation/config/trans.php | 34 + .../utils/translation/jobs/TranslationJob.php | 65 + .../utils/translation/queue/Translation.php | 50 + .../service/TranslationService.php | 43 + .../translation/service/TranslatorClient.php | 38 + app/common/validate/ApprovalFlowsValidate.php | 29 + .../validate/ApprovalInstancesValidate.php | 29 + .../validate/ApprovalRecordsValidate.php | 29 + app/common/validate/CorpFeishuAppValidate.php | 29 + app/common/validate/CorpValidate.php | 35 + app/common/validate/CountryValidate.php | 29 + .../validate/FinPaymentApplyValidate.php | 29 + app/common/validate/FinReceiptHisValidate.php | 29 + app/common/validate/FinReceiptValidate.php | 56 + .../FinanceReconDubaiFilesValidate.php | 29 + app/common/validate/InquiryLogValidate.php | 176 +++ .../InquiryTransmitConfigValidate.php | 29 + .../validate/InquiryTransmitValidate.php | 29 + app/common/validate/InvItemValidate.php | 29 + .../InvRequisitionApproveValidate.php | 29 + .../InvRequisitionAttachmentValidate.php | 29 + ...equisitionItemReturnAttachmentValidate.php | 29 + .../InvRequisitionItemReturnValidate.php | 29 + .../validate/InvRequisitionItemValidate.php | 29 + .../validate/InvRequisitionValidate.php | 55 + .../validate/MessageNotificationsValidate.php | 29 + .../validate/MessageUserStatusValidate.php | 29 + .../validate/PurchaseOrderItemsValidate.php | 29 + .../validate/PurchaseOrdersValidate.php | 29 + app/common/validate/ResourceValidate.php | 29 + app/common/validate/RoleValidate.php | 32 + .../validate/StockOrderAccountsValidate.php | 29 + .../StockOrderItemOptionsValidate.php | 29 + .../validate/StockOrderItemsValidate.php | 29 + .../validate/StockOrdersFilesValidate.php | 29 + app/common/validate/StockOrdersValidate.php | 209 +++ app/common/validate/SysOpLogValidate.php | 29 + app/common/validate/SysUpdateLogValidate.php | 29 + app/common/validate/SystemsValidate.php | 29 + app/common/validate/UploadedFilesValidate.php | 82 ++ .../UsedcarCrawlResultsImagesValidate.php | 29 + .../validate/UsedcarCrawlResultsValidate.php | 29 + .../validate/UserGroupMembersValidate.php | 29 + app/common/validate/UserGroupValidate.php | 29 + app/common/validate/UserValidate.php | 150 +++ app/common/validate/VehDictI18nValidate.php | 29 + .../validate/VehDictMappingValidate.php | 29 + app/common/validate/VehMoneyValueValidate.php | 29 + .../validate/VehVehicleListingValidate.php | 29 + app/common/validate/VehVehicleSkuValidate.php | 128 ++ .../validate/VehVehicleUnitValidate.php | 29 + app/controller/IndexController.php | 174 +++ app/crmAdmin/controller/BaseController.php | 51 + .../controller/CustomerController.php | 270 ++++ .../controller/StatisticsController.php | 187 +++ app/crmAdmin/logic/CustomerLogic.php | 34 + app/crmAdmin/service/CustomerService.php | 557 ++++++++ app/functions.php | 392 ++++++ app/middleware/AccessControl.php | 26 + app/middleware/Audit.php | 114 ++ app/middleware/AuthCheck.php | 50 + app/middleware/BasicData.php | 48 + app/middleware/StaticFile.php | 42 + app/mobile/controller/InvItemController.php | 55 + .../controller/InvRequisitionController.php | 55 + .../InvRequisitionItemReturnController.php | 56 + app/process/CheckWeb.php | 66 + app/process/Emails.php | 58 + app/process/Http.php | 10 + app/process/Monitor.php | 305 +++++ app/process/Task.php | 83 ++ app/queue/redis/ApprovalCallback.php | 83 ++ app/queue/redis/AuditLog.php | 72 ++ app/queue/redis/EmailMessage.php | 51 + app/queue/redis/FeishuMessage.php | 131 ++ app/queue/redis/InquiryToCrmNew.php | 222 ++++ app/queue/redis/InquiryToCrmNew_bak.php | 207 +++ app/queue/redis/Notice.php | 61 + app/queue/redis/PermAgt.php | 95 ++ app/queue/redis/Server.php | 57 + app/queue/redis/StockOrderPdf.php | 51 + app/queue/redis/WsMsg.php | 68 + app/queue/utils/Translat.php | 21 + app/queue/utils/XunfeiFanyi.php | 138 ++ .../controller/AttachmentController.php | 11 + app/ticket/controller/BaseController.php | 51 + app/ticket/controller/CategorieController.php | 64 + .../controller/ConversationController.php | 64 + app/ticket/controller/TicketController.php | 88 ++ app/ticket/logic/AttachmentLogic.php | 82 ++ app/ticket/logic/CategorieLogic.php | 74 ++ app/ticket/logic/ConversationLogic.php | 60 + app/ticket/logic/TicketLogic.php | 77 ++ app/ticket/model/Attachment.php | 66 + app/ticket/model/Categorie.php | 54 + app/ticket/model/CategorieTranslation.php | 35 + app/ticket/model/Conversation.php | 30 + app/ticket/model/Ticket.php | 95 ++ app/ticket/service/CategorieService.php | 105 ++ app/ticket/service/ConversationService.php | 160 +++ app/ticket/service/TicketService.php | 167 +++ app/view/index/qrcode.html | 46 + app/view/index/view.html | 14 + composer.json | 74 ++ config/app.php | 26 + config/autoload.php | 21 + config/bootstrap.php | 20 + config/cache.php | 31 + config/constants/Constants.php | 137 ++ config/container.php | 15 + config/database.php | 29 + config/dependence.php | 15 + config/exception.php | 17 + config/imap.php | 52 + config/jwt.php | 9 + config/log.php | 32 + config/middleware.php | 23 + config/oauth.php | 9 + config/oss.php | 10 + config/plugin/shopwwi/filesystem/app.php | 116 ++ config/plugin/webman/console/app.php | 24 + config/plugin/webman/gateway-worker/app.php | 4 + .../plugin/webman/gateway-worker/process.php | 39 + config/plugin/webman/log/app.php | 19 + config/plugin/webman/log/middleware.php | 21 + config/plugin/webman/redis-queue/app.php | 4 + config/plugin/webman/redis-queue/command.php | 7 + config/plugin/webman/redis-queue/log.php | 32 + config/plugin/webman/redis-queue/process.php | 12 + config/plugin/webman/redis-queue/redis.php | 22 + config/process.php | 72 ++ config/redis.php | 29 + config/route.php | 61 + config/server.php | 23 + config/session.php | 65 + config/static.php | 23 + config/translation.php | 25 + config/view.php | 22 + mzl_private.key | 28 + mzl_public.key | 9 + plugin/webman/gateway/Events.php | 39 + public/01.jpg | Bin 0 -> 36265 bytes public/db_log/审批.sql | 94 ++ public/db_log/物品领用.sql | 174 +++ public/favicon.ico | Bin 0 -> 15406 bytes public/mzl.html | 64 + resource/translations/ar/messages.php | 37 + resource/translations/en/messages.php | 37 + resource/translations/ru/messages.php | 37 + resource/translations/zh/messages.php | 37 + support/Request.php | 24 + support/Response.php | 24 + support/bootstrap.php | 139 ++ windows.bat | 3 + 451 files changed, 39954 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/bootstrap/Audit.php create mode 100644 app/command/CrmRotateUserCommand.php create mode 100644 app/command/CustomerCodeCommand.php create mode 100644 app/command/CustomerCreatedAtCommand.php create mode 100644 app/command/DomainCommand.php create mode 100644 app/command/EmailDetailCommand.php create mode 100644 app/command/EmailParseCommand.php create mode 100644 app/command/EmailUidCommand.php create mode 100644 app/command/FinanceRecoCommand.php create mode 100644 app/command/FixInquiryLogOrderNo.php create mode 100644 app/command/ImportCustomerCommand.php create mode 100644 app/command/InitUserPasswordCommand.php create mode 100644 app/command/TbCommand.php create mode 100644 app/command/UpdateInquiryLogFromCrmCustomerCommand.php create mode 100644 app/command/VehMoneyCommand.php create mode 100644 app/common/audit/AuditBuffer.php create mode 100644 app/common/controller/AuthController.php create mode 100644 app/common/controller/BaseController.php create mode 100644 app/common/controller/BrandController.php create mode 100644 app/common/controller/BrandSeriesController.php create mode 100644 app/common/controller/FinPaymentApplyController.php create mode 100644 app/common/controller/FinReceiptController.php create mode 100644 app/common/controller/FinReceiptHisController.php create mode 100644 app/common/controller/InvRequisitionApproveController.php create mode 100644 app/common/controller/InvRequisitionAttachmentController.php create mode 100644 app/common/controller/InvRequisitionItemController.php create mode 100644 app/common/controller/InvRequisitionItemReturnAttachmentController.php create mode 100644 app/common/controller/InvRequisitionItemReturnController.php create mode 100644 app/common/controller/MessageNotificationsController.php create mode 100644 app/common/controller/MessageUserStatusController.php create mode 100644 app/common/controller/NotifyController.php create mode 100644 app/common/controller/PresetController.php create mode 100644 app/common/controller/PurchaseOrderItemsController.php create mode 100644 app/common/controller/PurchaseOrdersController.php create mode 100644 app/common/controller/ResourceController.php create mode 100644 app/common/controller/StockOrdersFilesController.php create mode 100644 app/common/controller/SysOpLogController.php create mode 100644 app/common/controller/SysPortController.php create mode 100644 app/common/controller/SysUpdateLogController.php create mode 100644 app/common/controller/UploadController.php create mode 100644 app/common/controller/UsedcarCrawlResultsController.php create mode 100644 app/common/controller/UsedcarCrawlResultsImagesController.php create mode 100644 app/common/controller/UserController.php create mode 100644 app/common/controller/VehColorController.php create mode 100644 app/common/controller/VehDictI18nController.php create mode 100644 app/common/controller/VehDictMappingController.php create mode 100644 app/common/controller/VehModificationController.php create mode 100644 app/common/controller/VehMoneyValueController.php create mode 100644 app/common/controller/VehVehicleListingController.php create mode 100644 app/common/controller/VehVehicleSkuController.php create mode 100644 app/common/controller/VehVehicleUnitController.php create mode 100644 app/common/logic/ApprovalFlowsLogic.php create mode 100644 app/common/logic/ApprovalInstancesLogic.php create mode 100644 app/common/logic/ApprovalRecordsLogic.php create mode 100644 app/common/logic/AuthLogic.php create mode 100644 app/common/logic/BrandLogic.php create mode 100644 app/common/logic/BrandSeriesLogic.php create mode 100644 app/common/logic/CorpFeishuAppLogic.php create mode 100644 app/common/logic/CorpLogic.php create mode 100644 app/common/logic/CountryLogic.php create mode 100644 app/common/logic/FinPaymentApplyLogic.php create mode 100644 app/common/logic/FinReceiptHisLogic.php create mode 100644 app/common/logic/FinReceiptLogic.php create mode 100644 app/common/logic/FinanceReconDubaiFilesLogic.php create mode 100644 app/common/logic/Fingerprint.php create mode 100644 app/common/logic/InquiryTransmitConfigLogic.php create mode 100644 app/common/logic/InquiryTransmitLogic.php create mode 100644 app/common/logic/InvItemLogic.php create mode 100644 app/common/logic/InvRequisitionApproveLogic.php create mode 100644 app/common/logic/InvRequisitionAttachmentLogic.php create mode 100644 app/common/logic/InvRequisitionItemLogic.php create mode 100644 app/common/logic/InvRequisitionItemReturnAttachmentLogic.php create mode 100644 app/common/logic/InvRequisitionItemReturnLogic.php create mode 100644 app/common/logic/InvRequisitionLogic.php create mode 100644 app/common/logic/MessageNotificationsLogic.php create mode 100644 app/common/logic/MessageUserStatusLogic.php create mode 100644 app/common/logic/ModelNaming.php create mode 100644 app/common/logic/PermLogic.php create mode 100644 app/common/logic/PurchaseOrderItemsLogic.php create mode 100644 app/common/logic/PurchaseOrdersLogic.php create mode 100644 app/common/logic/ResourceLogic.php create mode 100644 app/common/logic/RoleLogic.php create mode 100644 app/common/logic/SkuCodeBuilder.php create mode 100644 app/common/logic/StockOrderAccountsLogic.php create mode 100644 app/common/logic/StockOrderItemOptionsLogic.php create mode 100644 app/common/logic/StockOrderItemsLogic.php create mode 100644 app/common/logic/StockOrdersFilesLogic.php create mode 100644 app/common/logic/StockOrdersLogic.php create mode 100644 app/common/logic/SysAccountLogic.php create mode 100644 app/common/logic/SysConfigLogic.php create mode 100644 app/common/logic/SysCurrencyLogic.php create mode 100644 app/common/logic/SysDictItemLogic.php create mode 100644 app/common/logic/SysDictLogic.php create mode 100644 app/common/logic/SysOpLogLogic.php create mode 100644 app/common/logic/SysPortLogic.php create mode 100644 app/common/logic/SysUpdateLogLogic.php create mode 100644 app/common/logic/SystemsLogic.php create mode 100644 app/common/logic/UploadedFilesLogic.php create mode 100644 app/common/logic/UsedcarCrawlResultsImagesLogic.php create mode 100644 app/common/logic/UsedcarCrawlResultsLogic.php create mode 100644 app/common/logic/UserGroupLogic.php create mode 100644 app/common/logic/UserGroupMembersLogic.php create mode 100644 app/common/logic/UserLogic.php create mode 100644 app/common/logic/VehColorLogic.php create mode 100644 app/common/logic/VehDictI18nLogic.php create mode 100644 app/common/logic/VehDictMappingLogic.php create mode 100644 app/common/logic/VehModificationLogic.php create mode 100644 app/common/logic/VehMoneyValueLogic.php create mode 100644 app/common/logic/VehVehicleListingLogic.php create mode 100644 app/common/logic/VehVehicleSkuLogic.php create mode 100644 app/common/logic/VehVehicleUnitLogic.php create mode 100644 app/common/model/ApprovalCcs.php create mode 100644 app/common/model/ApprovalFlows.php create mode 100644 app/common/model/ApprovalInstances.php create mode 100644 app/common/model/ApprovalRecords.php create mode 100644 app/common/model/Brand.php create mode 100644 app/common/model/BrandSeries.php create mode 100644 app/common/model/Corp.php create mode 100644 app/common/model/CorpFeishuApp.php create mode 100644 app/common/model/Country.php create mode 100644 app/common/model/CrmCustomer.php create mode 100644 app/common/model/CustomerAudit.php create mode 100644 app/common/model/EmailParsedResult.php create mode 100644 app/common/model/FinPaymentApply.php create mode 100644 app/common/model/FinPaymentStock.php create mode 100644 app/common/model/FinReceipt.php create mode 100644 app/common/model/FinReceiptHis.php create mode 100644 app/common/model/FinanceReconDubaiFiles.php create mode 100644 app/common/model/InquiryChannel.php create mode 100644 app/common/model/InquiryLog.php create mode 100644 app/common/model/InquiryTransmit.php create mode 100644 app/common/model/InquiryTransmitConfig.php create mode 100644 app/common/model/InvItem.php create mode 100644 app/common/model/InvRequisition.php create mode 100644 app/common/model/InvRequisitionApprove.php create mode 100644 app/common/model/InvRequisitionAttachment.php create mode 100644 app/common/model/InvRequisitionItem.php create mode 100644 app/common/model/InvRequisitionItemReturn.php create mode 100644 app/common/model/InvRequisitionItemReturnAttachment.php create mode 100644 app/common/model/MessageNotifications.php create mode 100644 app/common/model/MessageUserStatus.php create mode 100644 app/common/model/PurchaseOrderItems.php create mode 100644 app/common/model/PurchaseOrders.php create mode 100644 app/common/model/Resource.php create mode 100644 app/common/model/Role.php create mode 100644 app/common/model/SalesAnnualTargets.php create mode 100644 app/common/model/SalesMonthlyTargets.php create mode 100644 app/common/model/StockOrderAccounts.php create mode 100644 app/common/model/StockOrderItemOptions.php create mode 100644 app/common/model/StockOrderItems.php create mode 100644 app/common/model/StockOrders.php create mode 100644 app/common/model/StockOrdersFiles.php create mode 100644 app/common/model/SysAccount.php create mode 100644 app/common/model/SysConfig.php create mode 100644 app/common/model/SysCurrency.php create mode 100644 app/common/model/SysCurrencyTranslation.php create mode 100644 app/common/model/SysDict.php create mode 100644 app/common/model/SysDictCategory.php create mode 100644 app/common/model/SysDictItem.php create mode 100644 app/common/model/SysDictItemTranslation.php create mode 100644 app/common/model/SysDictTranslation.php create mode 100644 app/common/model/SysOpLog.php create mode 100644 app/common/model/SysPort.php create mode 100644 app/common/model/SysPortTranslation.php create mode 100644 app/common/model/SysUpdateLog.php create mode 100644 app/common/model/Systems.php create mode 100644 app/common/model/UploadedFiles.php create mode 100644 app/common/model/UsedcarCrawlResults.php create mode 100644 app/common/model/UsedcarCrawlResultsImages.php create mode 100644 app/common/model/User.php create mode 100644 app/common/model/UserGroup.php create mode 100644 app/common/model/UserGroupMembers.php create mode 100644 app/common/model/UserRoles.php create mode 100644 app/common/model/UserSn.php create mode 100644 app/common/model/UserSystems.php create mode 100644 app/common/model/UserTokenLog.php create mode 100644 app/common/model/VehColor.php create mode 100644 app/common/model/VehColorTranslation.php create mode 100644 app/common/model/VehDictI18n.php create mode 100644 app/common/model/VehDictMapping.php create mode 100644 app/common/model/VehModification.php create mode 100644 app/common/model/VehMoneyValue.php create mode 100644 app/common/model/VehMoneyValue_bak.php create mode 100644 app/common/model/VehVehicleListing.php create mode 100644 app/common/model/VehVehicleSku.php create mode 100644 app/common/model/VehVehicleUnit.php create mode 100644 app/common/service/ApprovalFlowsService.php create mode 100644 app/common/service/ApprovalInstancesService.php create mode 100644 app/common/service/ApprovalRecordsService.php create mode 100644 app/common/service/AuthService.php create mode 100644 app/common/service/BasicDataService.php create mode 100644 app/common/service/BrandSeriesService.php create mode 100644 app/common/service/BrandService.php create mode 100644 app/common/service/CorpFeishuAppService.php create mode 100644 app/common/service/CorpService.php create mode 100644 app/common/service/CrawlService.php create mode 100644 app/common/service/FinPaymentApplyService.php create mode 100644 app/common/service/FinReceiptHisService.php create mode 100644 app/common/service/FinReceiptService.php create mode 100644 app/common/service/FinanceReconDubaiFilesService.php create mode 100644 app/common/service/InquiryTransmitConfigService.php create mode 100644 app/common/service/InquiryTransmitService.php create mode 100644 app/common/service/InvItemService.php create mode 100644 app/common/service/InvRequisitionApproveService.php create mode 100644 app/common/service/InvRequisitionAttachmentService.php create mode 100644 app/common/service/InvRequisitionItemReturnAttachmentService.php create mode 100644 app/common/service/InvRequisitionItemReturnService.php create mode 100644 app/common/service/InvRequisitionItemService.php create mode 100644 app/common/service/InvRequisitionService.php create mode 100644 app/common/service/MenuBadgeService.php create mode 100644 app/common/service/MessageNotificationsService.php create mode 100644 app/common/service/MessageUserStatusService.php create mode 100644 app/common/service/OssService.php create mode 100644 app/common/service/PermService.php create mode 100644 app/common/service/PermService_bak.php create mode 100644 app/common/service/PurchaseOrderItemsService.php create mode 100644 app/common/service/PurchaseOrdersService.php create mode 100644 app/common/service/ResourceService.php create mode 100644 app/common/service/StockOrderAccountsService.php create mode 100644 app/common/service/StockOrderItemOptionsService.php create mode 100644 app/common/service/StockOrderItemsService.php create mode 100644 app/common/service/StockOrdersFilesService.php create mode 100644 app/common/service/StockOrdersService.php create mode 100644 app/common/service/SysAccountService.php create mode 100644 app/common/service/SysConfigService.php create mode 100644 app/common/service/SysCurrencyService.php create mode 100644 app/common/service/SysDictItemService.php create mode 100644 app/common/service/SysDictService.php create mode 100644 app/common/service/SysOpLogService.php create mode 100644 app/common/service/SysPortService.php create mode 100644 app/common/service/SysUpdateLogService.php create mode 100644 app/common/service/SystemsService.php create mode 100644 app/common/service/UploadService.php create mode 100644 app/common/service/UploadedFilesService.php create mode 100644 app/common/service/UsedcarCrawlResultsImagesService.php create mode 100644 app/common/service/UsedcarCrawlResultsService.php create mode 100644 app/common/service/UserGroupMembersService.php create mode 100644 app/common/service/UserGroupService.php create mode 100644 app/common/service/UserService.php create mode 100644 app/common/service/VehColorService.php create mode 100644 app/common/service/VehDictI18nService.php create mode 100644 app/common/service/VehDictMappingService.php create mode 100644 app/common/service/VehModificationService.php create mode 100644 app/common/service/VehMoneyValueService.php create mode 100644 app/common/service/VehVehicleListingService.php create mode 100644 app/common/service/VehVehicleSkuService.php create mode 100644 app/common/service/VehVehicleUnitService.php create mode 100644 app/common/traits/Auditable.php create mode 100644 app/common/traits/DataPermission.php create mode 100644 app/common/traits/DataTransformer.php create mode 100644 app/common/traits/FormatKvTrait.php create mode 100644 app/common/traits/HasMoneyJournal.php create mode 100644 app/common/traits/RequestTrait.php create mode 100644 app/common/traits/ResponseTrait.php create mode 100644 app/common/traits/crud/CrudControllerTrait.php create mode 100644 app/common/traits/crud/CrudLogicTrait.php create mode 100644 app/common/traits/crud/CrudServiceTrait.php create mode 100644 app/common/traits/crud/CrudServiceTrait_bak.php create mode 100644 app/common/utils/CrudUtil.php create mode 100644 app/common/utils/CrudUtil_bak.php create mode 100644 app/common/utils/Feishu.php create mode 100644 app/common/utils/FeishuMessage.php create mode 100644 app/common/utils/Jwt.php create mode 100644 app/common/utils/MigrationResources.php create mode 100644 app/common/utils/OauthClients.php create mode 100644 app/common/utils/RedisUidRotator.php create mode 100644 app/common/utils/TableInspector.php create mode 100644 app/common/utils/Tree.php create mode 100644 app/common/utils/UidRotator.php create mode 100644 app/common/utils/WsEvents.php create mode 100644 app/common/utils/WsResponse.php create mode 100644 app/common/utils/crawl/service/UsedCarCrawl.php create mode 100644 app/common/utils/crawl/service/UsedCarCrawl_bak.php create mode 100644 app/common/utils/crawl/service/UsedCarCrawl_bak2.php create mode 100644 app/common/utils/email/Emails.php create mode 100644 app/common/utils/email/MailSend.php create mode 100644 app/common/utils/excel/ExcelImportTrait.php create mode 100644 app/common/utils/excel/ExcelReader.php create mode 100644 app/common/utils/pdf/controller/PdfController.php create mode 100644 app/common/utils/pdf/service/MpdfDriver.php create mode 100644 app/common/utils/pdf/service/PdfHelper.php create mode 100644 app/common/utils/pdf/service/PdfStorage.php create mode 100644 app/common/utils/pdf/template/stockOrder copy 2.stub create mode 100644 app/common/utils/pdf/template/stockOrder copy.stub create mode 100644 app/common/utils/pdf/template/stockOrder.stub create mode 100644 app/common/utils/scaffold/Generate.php create mode 100644 app/common/utils/scaffold/templates/ControllerStub.stub create mode 100644 app/common/utils/scaffold/templates/Email.stub create mode 100644 app/common/utils/scaffold/templates/LogicStub.stub create mode 100644 app/common/utils/scaffold/templates/ModelStub.stub create mode 100644 app/common/utils/scaffold/templates/ServiceStub.stub create mode 100644 app/common/utils/scaffold/templates/ValidateStub.stub create mode 100644 app/common/utils/scaffold/templates/paymentFiles.stub create mode 100644 app/common/utils/scaffold/templates/workOrder.stub create mode 100644 app/common/utils/translation/config/trans.php create mode 100644 app/common/utils/translation/jobs/TranslationJob.php create mode 100644 app/common/utils/translation/queue/Translation.php create mode 100644 app/common/utils/translation/service/TranslationService.php create mode 100644 app/common/utils/translation/service/TranslatorClient.php create mode 100644 app/common/validate/ApprovalFlowsValidate.php create mode 100644 app/common/validate/ApprovalInstancesValidate.php create mode 100644 app/common/validate/ApprovalRecordsValidate.php create mode 100644 app/common/validate/CorpFeishuAppValidate.php create mode 100644 app/common/validate/CorpValidate.php create mode 100644 app/common/validate/CountryValidate.php create mode 100644 app/common/validate/FinPaymentApplyValidate.php create mode 100644 app/common/validate/FinReceiptHisValidate.php create mode 100644 app/common/validate/FinReceiptValidate.php create mode 100644 app/common/validate/FinanceReconDubaiFilesValidate.php create mode 100644 app/common/validate/InquiryLogValidate.php create mode 100644 app/common/validate/InquiryTransmitConfigValidate.php create mode 100644 app/common/validate/InquiryTransmitValidate.php create mode 100644 app/common/validate/InvItemValidate.php create mode 100644 app/common/validate/InvRequisitionApproveValidate.php create mode 100644 app/common/validate/InvRequisitionAttachmentValidate.php create mode 100644 app/common/validate/InvRequisitionItemReturnAttachmentValidate.php create mode 100644 app/common/validate/InvRequisitionItemReturnValidate.php create mode 100644 app/common/validate/InvRequisitionItemValidate.php create mode 100644 app/common/validate/InvRequisitionValidate.php create mode 100644 app/common/validate/MessageNotificationsValidate.php create mode 100644 app/common/validate/MessageUserStatusValidate.php create mode 100644 app/common/validate/PurchaseOrderItemsValidate.php create mode 100644 app/common/validate/PurchaseOrdersValidate.php create mode 100644 app/common/validate/ResourceValidate.php create mode 100644 app/common/validate/RoleValidate.php create mode 100644 app/common/validate/StockOrderAccountsValidate.php create mode 100644 app/common/validate/StockOrderItemOptionsValidate.php create mode 100644 app/common/validate/StockOrderItemsValidate.php create mode 100644 app/common/validate/StockOrdersFilesValidate.php create mode 100644 app/common/validate/StockOrdersValidate.php create mode 100644 app/common/validate/SysOpLogValidate.php create mode 100644 app/common/validate/SysUpdateLogValidate.php create mode 100644 app/common/validate/SystemsValidate.php create mode 100644 app/common/validate/UploadedFilesValidate.php create mode 100644 app/common/validate/UsedcarCrawlResultsImagesValidate.php create mode 100644 app/common/validate/UsedcarCrawlResultsValidate.php create mode 100644 app/common/validate/UserGroupMembersValidate.php create mode 100644 app/common/validate/UserGroupValidate.php create mode 100644 app/common/validate/UserValidate.php create mode 100644 app/common/validate/VehDictI18nValidate.php create mode 100644 app/common/validate/VehDictMappingValidate.php create mode 100644 app/common/validate/VehMoneyValueValidate.php create mode 100644 app/common/validate/VehVehicleListingValidate.php create mode 100644 app/common/validate/VehVehicleSkuValidate.php create mode 100644 app/common/validate/VehVehicleUnitValidate.php create mode 100644 app/controller/IndexController.php create mode 100644 app/crmAdmin/controller/BaseController.php create mode 100644 app/crmAdmin/controller/CustomerController.php create mode 100644 app/crmAdmin/controller/StatisticsController.php create mode 100644 app/crmAdmin/logic/CustomerLogic.php create mode 100644 app/crmAdmin/service/CustomerService.php create mode 100644 app/functions.php create mode 100644 app/middleware/AccessControl.php create mode 100644 app/middleware/Audit.php create mode 100644 app/middleware/AuthCheck.php create mode 100644 app/middleware/BasicData.php create mode 100644 app/middleware/StaticFile.php create mode 100644 app/mobile/controller/InvItemController.php create mode 100644 app/mobile/controller/InvRequisitionController.php create mode 100644 app/mobile/controller/InvRequisitionItemReturnController.php create mode 100644 app/process/CheckWeb.php create mode 100644 app/process/Emails.php create mode 100644 app/process/Http.php create mode 100644 app/process/Monitor.php create mode 100644 app/process/Task.php create mode 100644 app/queue/redis/ApprovalCallback.php create mode 100644 app/queue/redis/AuditLog.php create mode 100644 app/queue/redis/EmailMessage.php create mode 100644 app/queue/redis/FeishuMessage.php create mode 100644 app/queue/redis/InquiryToCrmNew.php create mode 100644 app/queue/redis/InquiryToCrmNew_bak.php create mode 100644 app/queue/redis/Notice.php create mode 100644 app/queue/redis/PermAgt.php create mode 100644 app/queue/redis/Server.php create mode 100644 app/queue/redis/StockOrderPdf.php create mode 100644 app/queue/redis/WsMsg.php create mode 100644 app/queue/utils/Translat.php create mode 100644 app/queue/utils/XunfeiFanyi.php create mode 100644 app/ticket/controller/AttachmentController.php create mode 100644 app/ticket/controller/BaseController.php create mode 100644 app/ticket/controller/CategorieController.php create mode 100644 app/ticket/controller/ConversationController.php create mode 100644 app/ticket/controller/TicketController.php create mode 100644 app/ticket/logic/AttachmentLogic.php create mode 100644 app/ticket/logic/CategorieLogic.php create mode 100644 app/ticket/logic/ConversationLogic.php create mode 100644 app/ticket/logic/TicketLogic.php create mode 100644 app/ticket/model/Attachment.php create mode 100644 app/ticket/model/Categorie.php create mode 100644 app/ticket/model/CategorieTranslation.php create mode 100644 app/ticket/model/Conversation.php create mode 100644 app/ticket/model/Ticket.php create mode 100644 app/ticket/service/CategorieService.php create mode 100644 app/ticket/service/ConversationService.php create mode 100644 app/ticket/service/TicketService.php create mode 100644 app/view/index/qrcode.html create mode 100644 app/view/index/view.html create mode 100644 composer.json create mode 100644 config/app.php create mode 100644 config/autoload.php create mode 100644 config/bootstrap.php create mode 100644 config/cache.php create mode 100644 config/constants/Constants.php create mode 100644 config/container.php create mode 100644 config/database.php create mode 100644 config/dependence.php create mode 100644 config/exception.php create mode 100644 config/imap.php create mode 100644 config/jwt.php create mode 100644 config/log.php create mode 100644 config/middleware.php create mode 100644 config/oauth.php create mode 100644 config/oss.php create mode 100644 config/plugin/shopwwi/filesystem/app.php create mode 100644 config/plugin/webman/console/app.php create mode 100644 config/plugin/webman/gateway-worker/app.php create mode 100644 config/plugin/webman/gateway-worker/process.php create mode 100644 config/plugin/webman/log/app.php create mode 100644 config/plugin/webman/log/middleware.php create mode 100644 config/plugin/webman/redis-queue/app.php create mode 100644 config/plugin/webman/redis-queue/command.php create mode 100644 config/plugin/webman/redis-queue/log.php create mode 100644 config/plugin/webman/redis-queue/process.php create mode 100644 config/plugin/webman/redis-queue/redis.php create mode 100644 config/process.php create mode 100644 config/redis.php create mode 100644 config/route.php create mode 100644 config/server.php create mode 100644 config/session.php create mode 100644 config/static.php create mode 100644 config/translation.php create mode 100644 config/view.php create mode 100644 mzl_private.key create mode 100644 mzl_public.key create mode 100644 plugin/webman/gateway/Events.php create mode 100644 public/01.jpg create mode 100644 public/db_log/审批.sql create mode 100644 public/db_log/物品领用.sql create mode 100644 public/favicon.ico create mode 100644 public/mzl.html create mode 100644 resource/translations/ar/messages.php create mode 100644 resource/translations/en/messages.php create mode 100644 resource/translations/ru/messages.php create mode 100644 resource/translations/zh/messages.php create mode 100644 support/Request.php create mode 100644 support/Response.php create mode 100644 support/bootstrap.php create mode 100644 windows.bat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92bf9b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +/runtime +/.idea +/.vscode +/vendor +*.log +.env +/tests/tmp +/tests/.phpunit.result.cache +/public/uploads +/webman +/start.php +/windows.php +/bootstrap.php +/composer.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2c66292 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 walkor and contributors (see https://github.com/walkor/webman/contributors) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4031784 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +
+

webman

+ +基于workerman开发的超高性能PHP框架 + + +

学习

+ + + +
+ +

赞助商

+ +

特别赞助

+ + + + +

铂金赞助

+ + + + +
+ + +
+ +

请作者喝咖啡

+ + + +
+如果您觉得webman对您有所帮助,欢迎捐赠。 + + +
+ + +
+

LICENSE

+The webman is open-sourced software licensed under the MIT. +
+ +
+ + diff --git a/app/bootstrap/Audit.php b/app/bootstrap/Audit.php new file mode 100644 index 0000000..f94327c --- /dev/null +++ b/app/bootstrap/Audit.php @@ -0,0 +1,31 @@ +pushModelChange($model->getTable(), $model->getKey(), [], $model->getAttributes()); + }); + Model::updated(function ($model) { + $dirty = $model->getDirty(); + if (!$dirty) return; + $old = []; $new = []; + foreach ($dirty as $k=>$v) { + $old[$k] = $model->getOriginal($k); + $new[$k] = $model->getAttribute($k); + } + AuditBuffer::instance()->pushModelChange($model->getTable(), $model->getKey(), $old, $new); + }); + Model::deleted(function ($model) { + AuditBuffer::instance()->pushModelChange($model->getTable(), $model->getKey(), $model->getAttributes(), []); + }); + } + +} \ No newline at end of file diff --git a/app/command/CrmRotateUserCommand.php b/app/command/CrmRotateUserCommand.php new file mode 100644 index 0000000..5defb24 --- /dev/null +++ b/app/command/CrmRotateUserCommand.php @@ -0,0 +1,105 @@ +setHelp('查询 user 表中 status=2 且 corp_id=3 的用户, +然后查找其关联的 crm_customer 记录, +按顺序将这些 crm_customer.user_id 轮流设置为 1、2、3。'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $corpId = 3; + $status = 2; + $roundRobinUserIds = [64,63,83,93,118,131,132,143,145,150,156,157,158,160]; + + $output->writeln('开始执行 crm:rotate-user'); + + try { + // 1) 找到 user 表中满足条件的用户ID,派卡离职用户 + $userRows = Db::table('user') + ->where('corp_id', $corpId) + ->where('status', $status) + ->get(['id','name']); + + $userIds = []; + $userNames = []; + foreach ($userRows as $row) { + $userIds[] = (int)$row->id; + $userNames[] = $row->name; + } + + if (empty($userIds)) { + $output->writeln('未找到满足条件的用户(status=2 且 corp_id=3)。无需处理。'); + return self::SUCCESS; + } + + $output->writeln('匹配到用户数:' . count($userIds) .' '. implode(',',$userNames) . ''); + + // 2) 查找 crm_customer 中属于这些用户的记录 + $customers = Db::table('crm_customer') + ->whereIn('user_id', $userIds) + ->orderBy('id', 'asc') + ->get(['id','user_id']); + + if (!$customers || count($customers) === 0) { + $output->writeln('未找到相关的 crm_customer 记录。'); + return self::SUCCESS; + } + + $output->writeln('需要处理的 crm_customer 数量:' . count($customers) . ''); + + // 3) 事务中按顺序轮流设置 + // 初始化 UID 轮询器 + $uidRotator = new \app\common\utils\RedisUidRotator('email_parsed_result_20250804'); + $uidRotator->setUids([64, 63, 83, 93, 118, 131, 132, 143, 145, 150, 156, 157, 158, 160], false); + + Db::connection()->transaction(function () use ($customers, $roundRobinUserIds, $output, $uidRotator) { + $assignCount = 0; + $targetCount = count($roundRobinUserIds); + + foreach ($customers as $index => $customer) { + // 获取下一个分配的UID + $newUserId = $uidRotator->next(); + Db::table('crm_customer') + ->where('id', $customer->id) + ->update(['user_id' => $newUserId]); + + $his = CustomerHis::create([ + 'customer_id' => $customer->id, + 'user_his' => $customer->user_id, + 'user_id' => $newUserId, + ]); + + + $assignCount++; + + if ($assignCount % 500 === 0) { + $output->writeln('已处理:' . $assignCount . ''); + } + } + + $output->writeln('处理完成,共更新 crm_customer:' . $assignCount . ' 条'); + }); + + return self::SUCCESS; + } catch (\Throwable $e) { + $output->writeln('执行失败:' . $e->getMessage() . ''); + return self::FAILURE; + } + } +} + diff --git a/app/command/CustomerCodeCommand.php b/app/command/CustomerCodeCommand.php new file mode 100644 index 0000000..a82ec0f --- /dev/null +++ b/app/command/CustomerCodeCommand.php @@ -0,0 +1,62 @@ +setDescription('为crm_customer表所有记录生成唯一code码,分批处理'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $batchSize = 1000; + $total = Customer::withTrashed()->where('code','')->count(); + $output->writeln("总计需处理:$total 条记录"); + + $processed = 0; + do { + // 查询一批未生成code的记录 + $customers = Customer::withTrashed()->where('code','')->limit($batchSize)->get(); + if ($customers->isEmpty()) { + break; + } + + // Eloquent 批量处理 + foreach ($customers as $customer) { + // 生成唯一16位code,CM+14位 + $unique = strtoupper(substr(md5($customer->id . microtime(true) . mt_rand(1000, 9999)), 0, 14)); + $code = 'CM' . $unique; + + // 防止极小概率重复 + while (Customer::where('code', $code)->exists()) { + $unique = strtoupper(substr(md5($customer->id . microtime(true) . mt_rand(1000, 9999)), 0, 14)); + $code = 'CM' . $unique; + } + + $created_at = $customer->created_at->getTimestamp(); + $obtain = $created_at > strtotime('2025-07-29')?$created_at:strtotime('2025-07-29'); + + $customer->code = $code; + $customer->obtain = $obtain; + $customer->save(); + $processed++; + } + + $output->writeln("已处理:$processed / $total"); + } while (true); + + $output->writeln('全部处理完成!'); + return self::SUCCESS; + } +} \ No newline at end of file diff --git a/app/command/CustomerCreatedAtCommand.php b/app/command/CustomerCreatedAtCommand.php new file mode 100644 index 0000000..57a4020 --- /dev/null +++ b/app/command/CustomerCreatedAtCommand.php @@ -0,0 +1,69 @@ +setHelp('该命令将查询 crm_customer 表中 sn 不为空的记录,并根据 sn 在 inquiry_log 表中查找对应的 order_no 记录,将 inquiry_log 的 created_at 更新到 crm_customer 的 created_at 字段'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $output->writeln('开始更新 crm_customer 的 created_at 字段...'); + + try { + // 使用事务确保数据一致性 + DB::transaction(function () use ($output) { + // 查询所有 sn 字段不为空的客户记录 + $customers = Db::table('crm_customer') + ->where('sn', '<>', '') + ->whereNotNull('sn') + ->get(['id', 'sn']); + $output->writeln(''.count($customers).''); + + $updatedCount = 0; + + foreach ($customers as $customer) { + // 查找匹配的 inquiry_log 记录 + $inquiryLog = Db::table('inquiry_log') + ->where('order_no', $customer->sn) + ->first(['r_time','created_at']); + + // 如果找到匹配记录,则更新客户的 created_at 字段 + if ($inquiryLog && isset($inquiryLog->r_time)) { + $created_at = $inquiryLog->r_time?:$inquiryLog->created_at; + DB::table('crm_customer') + ->where('id', $customer->id) + ->update(['created_at' => $created_at]); + + $updatedCount++; + $output->writeln("已更新客户 ID {$customer->id} 的 created_at 为: {$created_at}"); + } + } + + $output->writeln("更新完成,总共更新了 {$updatedCount} 条记录。"); + }); + + return self::SUCCESS; + } catch (\Exception $e) { + $output->writeln('执行过程中发生错误: ' . $e->getMessage() . ''); + return self::FAILURE; + } + } +} \ No newline at end of file diff --git a/app/command/DomainCommand.php b/app/command/DomainCommand.php new file mode 100644 index 0000000..7d09da5 --- /dev/null +++ b/app/command/DomainCommand.php @@ -0,0 +1,79 @@ +orderBy('id', 'desc')->get(); + + foreach ($list as $item) { + if (!$item->content) { + continue; + } + $item->content = processRichTextImagesEnhanced($item->content); + $item->save(); + $output->writeln("处理了{$item->id} \n"); + } + return self::SUCCESS; + } +} + +/** + * 增强版 - 处理富文本内容中的图片URL + * + * @param string $content 富文本HTML内容 + * @param string $newDomain 新域名(如: https://fdesk.hnxinyouhe.com) + * @param string $oldDomain 旧域名(如: http://219.157.255.213:25326) + * @return string 处理后的富文本内容 + */ +function processRichTextImagesEnhanced($content, $newDomain = 'https://x-server.hnxinyouhe.com', $oldDomain = 'http://219.157.255.213:25326') { + // 标准化域名格式(去除末尾斜杠) + $newDomain = rtrim($newDomain, '/'); + $oldDomain = rtrim($oldDomain, '/'); + + // 要处理的属性列表 + $attributes = ['src', 'data-href', 'href', 'data-src', 'data-original']; + + foreach ($attributes as $attr) { + // 情况1:替换旧域名为新域名 + $content = preg_replace( + '/('.$attr.'=["\'])'.preg_quote($oldDomain, '/').'(\/uploads\/)/i', + '$1' . $newDomain . '$2', + $content + ); + + // 情况2:给无域名的路径添加新域名 + $content = preg_replace( + '/('.$attr.'=["\'])(\/uploads\/)/i', + '$1' . $newDomain . '$2', + $content + ); + + // 情况3:处理可能存在的其他域名(可选) + $content = preg_replace( + '/('.$attr.'=["\'])(https?:\/\/[^\/]+)(\/uploads\/)/i', + '$1' . $newDomain . '$3', + $content + ); + } + + return $content; +} \ No newline at end of file diff --git a/app/command/EmailDetailCommand.php b/app/command/EmailDetailCommand.php new file mode 100644 index 0000000..78e5720 --- /dev/null +++ b/app/command/EmailDetailCommand.php @@ -0,0 +1,206 @@ +addOption( + 'per-page', 'p', + InputOption::VALUE_OPTIONAL, + '每批处理多少条详情', + config('imap.per_page', 50) + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $cfg = config('imap'); + $mailbox = $cfg['mailbox']; + $username = $cfg['username']; + $password = $cfg['password']; + $batch = (int)$input->getOption('per-page'); + + // 打开 IMAP + $inbox = @imap_open($mailbox, $username, $password); + if (!$inbox) { + $output->writeln("IMAP 连接失败:". imap_last_error() .""); + return Command::FAILURE; + } + + // 阶段二:逐批拉取详情并写入 emails 表 + $output->writeln("依次拉取详情并写入 emails 表 …"); + while (true) { + $pending = Db::table('email_uids') + ->where('status', 0) + // ->where('uid', '>', 13399) + ->orderBy('uid', 'desc') + ->limit(2) + ->get(); + + if (count($pending) === 0) { + $output->writeln("所有 UID 均已处理完毕。"); + break; + } + + $output->writeln("处理下一批 " . count($pending) . " 条 …"); + + foreach ($pending as $row) { + $uid = $row->uid; + + // 包裹整行处理在 try/catch 中,任何异常都不会中断循环 + try { + $output->writeln("---- 开始处理 UID={$uid} ----"); + + // 1. fetch overview + $ov = @imap_fetch_overview($inbox, $uid, FT_UID)[0] ?? null; + if (!$ov) { + $err = imap_last_error() ?: '未知错误'; + $output->writeln("UID={$uid} overview 失败:{$err}"); + $this->logError("UID={$uid} overview 失败:{$err}"); + continue; + } + $subject = mb_convert_encoding($ov->subject ?? '', 'UTF-8', 'GBK,UTF-8'); + $from = $ov->from ?? ''; + $date = $ov->date ?? ''; + + $output->writeln("UID={$uid} overview 成功,subject={$subject}"); + + // 2. fetch body + $body = $this->getMailBody($inbox, $uid, true); + $output->writeln("UID={$uid} body 长度:" . strlen($body)); + + // 3. sanitize & insert + $body = $this->sanitizeUtf8($body); + $now = time(); + + try { + Db::table('emails')->insert([ + 'uid' => $uid, + 'subject' => $subject, + 'from' => $from, + 'date' => $date, + 'body' => $body, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $output->writeln("UID={$uid} 写入 emails 成功"); + } catch (\Throwable $e) { + $output->writeln("UID={$uid} 写入失败:{$e->getMessage()}"); + $this->logError("UID={$uid} 写入失败:{$e->getMessage()}"); + continue; + } + + // 4. 标记已处理 + try { + Db::table('email_uids') + ->where('uid', $uid) + ->update([ + 'status' => 1, + 'processed_at' => $now, + ]); + $output->writeln("UID={$uid} 标记为已处理"); + } catch (\Throwable $e) { + $output->writeln("UID={$uid} 标记状态失败:{$e->getMessage()}"); + $this->logError("UID={$uid} 标记状态失败:{$e->getMessage()}"); + // 即使更新 status 失败,也继续下一条 + } + } catch (\Throwable $e) { + // 捕获任何非预期错误 + $output->writeln("UID={$uid} 处理异常:{$e->getMessage()}"); + $this->logError("UID={$uid} 处理异常:{$e->getMessage()}"); + continue; + } + } + + // 批次结束后稍作休眠,防止服务器过载 + sleep(1); + } + + imap_close($inbox); + $output->writeln("\n✅ 全部邮件处理完成。"); + return Command::SUCCESS; + } catch (\Throwable $th) { + dpr($th); + return Command::FAILURE; + } + } + + /** + * 将任意字符串清洗为有效 UTF-8,移除或替换无效字节 + */ + protected function sanitizeUtf8(string $text): string + { + return iconv('UTF-8', 'UTF-8//IGNORE', $text); + } + + /** + * 记录错误到日志 + */ + protected function logError(string $msg): void + { + $line = '[' . date('Y-m-d H:i:s') . '] ' . $msg . "\n"; + file_put_contents( + runtime_path('logs/email_errors.log'), + $line, + FILE_APPEND | LOCK_EX + ); + } + + /** + * 按编码解码并保留 "\r\n" + */ + protected function getMailBody($inbox, int $id, bool $isUid = false): string + { + $flags = $isUid ? FT_UID : 0; + $structure = imap_fetchstructure($inbox, $id, $flags); + + if (!empty($structure->parts)) { + // HTML 部分 + foreach ($structure->parts as $i => $part) { + if (isset($part->subtype) && strtolower($part->subtype) === 'html') { + $data = imap_fetchbody($inbox, $id, (string)($i+1), $flags); + return $this->decodeBody($data, $part->encoding); + } + } + // 文本部分 + foreach ($structure->parts as $i => $part) { + if (isset($part->subtype) && strtolower($part->subtype) === 'plain') { + $data = imap_fetchbody($inbox, $id, (string)($i+1), $flags); + return $this->decodeBody($data, $part->encoding); + } + } + // 原始 body + return imap_body($inbox, $id, $flags); + } + + return imap_body($inbox, $id, $flags); + } + + /** + * 按编码解码 + */ + protected function decodeBody(string $body, int $encoding): string + { + switch ($encoding) { + case 3: return imap_base64($body); + case 4: return imap_qprint($body); + default: return $body; + } + } +} \ No newline at end of file diff --git a/app/command/EmailParseCommand.php b/app/command/EmailParseCommand.php new file mode 100644 index 0000000..3bf21f7 --- /dev/null +++ b/app/command/EmailParseCommand.php @@ -0,0 +1,144 @@ +orderBy('id', 'asc') + ->limit($batchSize) + ->offset($page * $batchSize) + ->get(); + + if ($rows->isEmpty()) { + break; + } + + $toInsert = []; + foreach ($rows as $row) { + $parsed = $this->parseHtml($row->body); + if ($parsed === null) { + $output->writeln(" ↳ [{$row->id}] skip: not a contact-form HTML"); + continue; + } + + // 严格按 $fields 顺序填充每条记录,缺失的字段补 null + $record = []; + foreach ($fields as $col) { + if ($col === 'email_id') { + $record[$col] = $row->id; + } else { + $record[$col] = $parsed[$col] ?? null; + } + } + + $toInsert[] = $record; + } + + if (!empty($toInsert)) { + Db::table('email_parsed_result')->insert($toInsert); + $output->writeln("Inserted batch #" . ($page + 1) . ", count: " . count($toInsert)); + } + + $page++; + } + + $output->writeln("✔ All done."); + return Command::SUCCESS; + } + + /** + * 解析 HTML,找不到

Preview

则返回 null, + * 否则提取所有 Key: Value 为小写蛇形 key=>value 数组 + */ + protected function parseHtml(string $html): ?array + { + $doc = new \DOMDocument(); + libxml_use_internal_errors(true); + $doc->loadHTML('' . $html); + libxml_clear_errors(); + + $xpath = new \DOMXPath($doc); + + // 类型检查 + if ($xpath->query("//h2[text()='Preview']")->length === 0) { + return null; + } + + $nodes = $xpath->query("//div[span]"); + $data = []; + + /** @var \DOMElement $node */ + foreach ($nodes as $node) { + $span = $xpath->query("span", $node)->item(0); + if (! $span) { + continue; + } + + // 原始键名 + $key = trim(str_replace(':', '', $span->textContent)); + // 转成小写蛇形 + $col = strtolower(str_replace(' ', '_', $key)); + + // 获取值 + $a = $xpath->query(".//a", $node)->item(0); + if ($a && $a->hasAttribute('href')) { + $value = trim($a->getAttribute('href')); + } else { + $clone = $node->cloneNode(true); + $spanClone = $xpath->query(".//span", $clone)->item(0); + if ($spanClone) { + $spanClone->parentNode->removeChild($spanClone); + } + $value = trim($clone->textContent); + } + + $data[$col] = $value; + } + + return $data; + } +} \ No newline at end of file diff --git a/app/command/EmailUidCommand.php b/app/command/EmailUidCommand.php new file mode 100644 index 0000000..bf0e744 --- /dev/null +++ b/app/command/EmailUidCommand.php @@ -0,0 +1,185 @@ +addOption( + // 'per-page', 'p', + // InputOption::VALUE_OPTIONAL, + // '每批处理多少条详情', + // config('imap.per_page', 50) + // ); + // } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $cfg = config('imap'); + $mailbox = $cfg['mailbox']; + $username = $cfg['username']; + $password = $cfg['password']; + // $batch = (int)$input->getOption('per-page'); + + $retryTimes = 3; + $retryDelay = 10; // 秒 + + $inbox = false; + $attempt = 0; + + $maxUid = Db::table('email_uids')->max('uid'); + + while ($attempt < $retryTimes) { + $attempt++; + $output->writeln("尝试第 {$attempt} 次连接 IMAP..."); + $inbox = @imap_open($mailbox, $username, $password); + + if ($inbox) { + break; + } + + $error = imap_last_error(); + $output->writeln("第 {$attempt} 次失败:{$error}"); + + // 若出现 too many login failures 立即暂停一段时间 + if (stripos($error, 'Too many login failures') !== false) { + $output->writeln("检测到登录失败次数过多,暂停 60 秒避免封禁"); + sleep(60); + } else { + sleep($retryDelay); + } + } + + if (!$inbox) { + $output->writeln("最终仍然连接失败:" . imap_last_error() . ""); + return Command::FAILURE; + } + + // 拉取 UID 并入库 + $output->writeln("开始拉取 UID 并入库 …"); + $uids = imap_search($inbox, 'ALL', SE_UID) ?: []; + + $uids = array_filter($uids, function($uid) use ($maxUid) { + return $uid > $maxUid; + }); + + sort($uids); + $now = time(); + $count = 0; + + foreach ($uids as $uid) { + try { + Db::table('email_uids')->insert([ + 'uid' => $uid, + 'fetched_at' => $now, + 'email' => $cfg['username'] + ]); + $count++; + + $output->writeln("UID:".$uid." 已写入数据库"); + } catch (\Throwable $e) { + $output->writeln("{$e->getMessage()}"); + } + } + + imap_close($inbox); + $output->writeln(date('Y-m-d H:i:s').":共拉到 {$count} 个 UID 并写入 email_uids 表。"); + return Command::SUCCESS; + } catch (\Throwable $th) { + dpr($th); + return Command::FAILURE; + } + } +} + +/** + * 获取指定格式的日期字符串 + * + * @param string|null $input_date 可选,指定的日期(可以是任何PHP可解析的日期格式) + * @return string 返回格式为 'd-M-Y' 的日期字符串(如 '28-Jul-2025') + */ +function getImapFormattedDate($input_date = null) { + // 如果有传入日期参数,尝试解析 + if ($input_date !== null) { + $timestamp = strtotime($input_date); + if ($timestamp === false) { + // 如果日期解析失败,使用当前日期并记录警告 + trigger_error("无法解析提供的日期: '$input_date',将使用当前日期", E_USER_WARNING); + $timestamp = time(); + } + } else { + // 如果没有提供日期,使用当前日期 + $timestamp = time(); + } + + // 格式化为 IMAP 需要的日期格式 (如 '28-Jul-2025') + return date('d-M-Y', $timestamp); +} + +// // 使用示例 +// echo getImapFormattedDate(); // 输出当天的日期,如 '31-Jul-2025' +// echo getImapFormattedDate('2025-07-28'); // 输出 '28-Jul-2025' +// echo getImapFormattedDate('28/07/2025'); // 输出 '28-Jul-2025' +// echo getImapFormattedDate('next Monday'); // 输出下个周一的日期 + +/* + +// imap_search() 条件 + +// 1. 基本日期搜索(按接收日期) +// 搜索今天收到的邮件 +$today = date("d-M-Y"); +$uids = imap_search($inbox, 'SINCE "' . $today . '"', SE_UID); + +// 搜索特定日期之后的邮件 +$since_date = '15-Jul-2023'; +$uids = imap_search($inbox, 'SINCE "' . $since_date . '"', SE_UID); + +// 搜索特定日期之前的邮件 +$before_date = '20-Jul-2023'; +$uids = imap_search($inbox, 'BEFORE "' . $before_date . '"', SE_UID); + +// 搜索特定日期的邮件 +$on_date = '18-Jul-2023'; +$uids = imap_search($inbox, 'ON "' . $on_date . '"', SE_UID); + +// 2. 日期范围搜索 +// 搜索2023年7月15日到2023年7月20日之间的邮件 +$start_date = '15-Jul-2023'; +$end_date = '20-Jul-2023'; +$uids = imap_search($inbox, 'SINCE "' . $start_date . '" BEFORE "' . $end_date . '"', SE_UID); + +// 3. 结合日期和其他条件 +// 搜索2023年7月以后未读的邮件 +$date = '1-Jul-2023'; +$uids = imap_search($inbox, 'SINCE "' . $date . '" UNSEEN', SE_UID); + +// 搜索2023年7月来自某人的邮件 +$date = '1-Jul-2023'; +$from = 'example@domain.com'; +$uids = imap_search($inbox, 'SINCE "' . $date . '" FROM "' . $from . '"', SE_UID); + +// 4. 日期格式注意事项 +// - IMAP日期格式通常为 d-M-Y,例如 15-Jul-2023 +// - 月份使用英文缩写(Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec) +// - 日期格式必须用双引号括起来 + +所有ID +imap_search($inbox, 'ALL', SE_UID) + +*/ \ No newline at end of file diff --git a/app/command/FinanceRecoCommand.php b/app/command/FinanceRecoCommand.php new file mode 100644 index 0000000..1406ab9 --- /dev/null +++ b/app/command/FinanceRecoCommand.php @@ -0,0 +1,32 @@ +generatePrivateUrl('fdesk_test/20251104/20251104_财务.xlsx');; + + $service = new \app\common\service\FinanceReconDubaiFilesService(); + $result = $service->dImport('fdesk_test/20251104/20251104_迪拜.xlsx'); + + return self::SUCCESS; + } +} \ No newline at end of file diff --git a/app/command/FixInquiryLogOrderNo.php b/app/command/FixInquiryLogOrderNo.php new file mode 100644 index 0000000..91d3c04 --- /dev/null +++ b/app/command/FixInquiryLogOrderNo.php @@ -0,0 +1,103 @@ +writeln('Starting duplicate order_no fix process...'); + + // Find all duplicate order_no values using Laravel-style query + $duplicates = Db::table('inquiry_log') + ->select('order_no', Db::raw('COUNT(*) as count')) + ->groupBy('order_no') + ->having('count', '>', 1) + ->get(); + + if ($duplicates->isEmpty()) { + $output->writeln('No duplicate order_no values found.'); + Db::commit(); + return self::SUCCESS; + } + + $output->writeln(sprintf('Found %d order_no values with duplicates.', $duplicates->count())); + + $totalFixed = 0; + + foreach ($duplicates as $dup) { + $orderNo = $dup->order_no; + + $output->writeln("Processing duplicates for order_no: {$orderNo}"); + + // Get all records with this order_no, ordered by created_at (oldest first) + $records = Db::table('inquiry_log') + ->where('order_no', $orderNo) + ->orderBy('created_at', 'asc') + ->get(); + + // Keep the first record as is, modify the rest + $keepOriginal = true; + + foreach ($records as $record) { + if ($keepOriginal) { + $output->writeln("Keeping original record ID {$record->id} with order_no {$record->order_no}"); + $keepOriginal = false; + continue; + } + + // Generate new order_no by appending suffix + $newOrderNo = orderNum(); + $output->writeln("Updating record ID {$record->id} from {$record->order_no} to {$newOrderNo}"); + + // Update the record + Db::table('inquiry_log') + ->where('id', $record->id) + ->update(['order_no' => $newOrderNo]); + + $totalFixed++; + + sleep(1); + } + } + + Db::commit(); + $output->writeln("Successfully fixed {$totalFixed} duplicate records."); + + // Verify no duplicates remain + $remainingDuplicates = Db::table('inquiry_log') + ->select('order_no', Db::raw('COUNT(*) as count')) + ->groupBy('order_no') + ->having('count', '>', 1) + ->get(); + + if ($remainingDuplicates->isEmpty()) { + $output->writeln('Verification: No duplicate order_no values remain.'); + } else { + $output->writeln('Warning: Some duplicates still remain. Please run the command again.'); + } + + return self::SUCCESS; + } catch (\Throwable $e) { + Db::rollback(); + $output->writeln("Error: " . $e->getMessage()); + Log::error("FixInquiryLogOrderNo command failed: " . $e->getMessage()); + return self::FAILURE; + } + } +} \ No newline at end of file diff --git a/app/command/ImportCustomerCommand.php b/app/command/ImportCustomerCommand.php new file mode 100644 index 0000000..61551ac --- /dev/null +++ b/app/command/ImportCustomerCommand.php @@ -0,0 +1,178 @@ +addArgument('file', InputArgument::REQUIRED, 'Excel文件路径') +// ->addOption('user-id', 'u', InputOption::VALUE_REQUIRED, '用户ID', 0) +// ->addOption('channel-id', 'c', InputOption::VALUE_REQUIRED, '渠道ID', 5) +// ->addOption('corp-abbr', 'a', InputOption::VALUE_REQUIRED, '公司缩写', 2); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { +// $filePath = $input->getArgument('file'); +// $userId = $input->getOption('user-id'); +// $channelId = $input->getOption('channel-id'); +// $corpAbbr = $input->getOption('corp-abbr'); + $filePath = 'C:\Users\16961\Downloads\中国制造 2023-2025-询盘导出.xls'; + $userId = 0; + $channelId = 5; + $corpAbbr = 2; + + // 检查文件是否存在 + if (!file_exists($filePath)) { + $output->writeln("文件不存在: {$filePath}"); + return self::FAILURE; + } + + try { + $output->writeln("开始导入文件: {$filePath}"); + + // 加载Excel文件 + $output->writeln("正在加载Excel文件..."); + $spreadsheet = IOFactory::load($filePath); + $worksheet = $spreadsheet->getActiveSheet(); + $output->writeln("Excel文件加载成功"); + + // 获取数据范围 + $highestRow = $worksheet->getHighestRow(); + $highestColumn = $worksheet->getHighestColumn(); + + $output->writeln("文件包含 {$highestRow} 行数据"); + + // 获取所有国家数据,用于匹配 + $output->writeln("正在获取国家数据..."); +// $countries = Country::select('id', 'cn_addr', 'en_addr')->get()->keyBy(function($item) { +// return strtolower(trim($item->cn_addr)); +// }); +// $output->writeln("获取到 " . $countries->count() . " 个国家数据"); + + $successCount = 0; + $errorCount = 0; + $errors = []; + + // 从第二行开始读取数据(第一行是标题) + $output->writeln("开始读取Excel数据..."); + for ($row = 2; $row <= $highestRow; $row++) { + try { + $output->writeln("正在处理第 {$row} 行..."); + + // 读取各列数据 - 使用坐标方法 + try { + $countryName = trim($worksheet->getCell('B' . $row)->getValue() ?? ''); + $contacts = trim($worksheet->getCell('A' . $row)->getValue() ?? ''); + $email = trim($worksheet->getCell('C' . $row)->getValue() ?? ''); + $phone = trim($worksheet->getCell('D' . $row)->getValue() ?? ''); + $inquiryProduct = trim($worksheet->getCell('E' . $row)->getValue() ?? ''); + } catch (\Exception $cellError) { + $output->writeln("读取第{$row}行单元格时出错: " . $cellError->getMessage() . ""); + continue; + } + + $output->writeln("读取数据: 国家={$countryName}, 客户={$contacts}, 产品={$inquiryProduct}"); + + // 跳过空行 +// if (empty($contacts)) { +// continue; +// } + + // 查找国家ID + $countryId = 0; +// if (!empty($countryName)) { +// $countryKey = strtolower($countryName); +// if ($countries->has($countryKey)) { +// $countryId = $countries->get($countryKey)->id; +// } else { +// // 尝试英文名称匹配 +// $countryByEn = $countries->first(function($item) use ($countryName) { +// return strtolower(trim($item->en_addr)) === strtolower($countryName); +// }); +// if ($countryByEn) { +// $countryId = $countryByEn->id; +// } +// } +// } + + // 检查是否已存在相同客户名 +// $existingCustomer = Customer::where('contacts', $customerName)->first(); +// if ($existingCustomer) { +// $output->writeln("跳过已存在的客户: {$customerName}"); +// continue; +// } + + // 创建客户记录 +// $customerData = [ +// 'user_id' => $userId, +// 'corp_abbr' => $corpAbbr, +// 'channel_id' => $channelId, +// 'country_id' => $countryId, +// 'contacts' => $customerName, +// 'email' => $email, +// 'phone' => $phone, +// 'desc_collect' => $inquiryProduct, +// 'grade' => 0, // 默认等级 +// 'magnitude' => 1, // 默认商机 +// 'type' => 1, // 默认类型 +// 'sex' => 0, // 默认性别 +// 'enter_user_id' => $userId, +// ]; + $where = [ + ['id', '<', 28110], + ['id', '>', 25714], + ]; + $customer = Customer::where('contacts',$email)->where($where)->first(); + $customer->email = $email; + $customer->phone = $phone; + $customer->contacts = $contacts; + $customer->save(); + $successCount++; + + $output->writeln("成功导入客户: {$contacts} (ID: {$customer->id})"); + + } catch (\Exception $e) { + $errorCount++; + $errorMsg = "第{$row}行导入失败: " . $e->getMessage(); + $errorMsg .= "\n文件: " . $e->getFile() . " 行: " . $e->getLine(); + $errorMsg .= "\n堆栈: " . $e->getTraceAsString(); + $errors[] = $errorMsg; + $output->writeln("{$errorMsg}"); + } + } + + $output->writeln("\n导入完成!"); + $output->writeln("成功导入: {$successCount} 条记录"); + $output->writeln("失败: {$errorCount} 条记录"); + + if (!empty($errors)) { + $output->writeln("\n错误详情:"); + foreach ($errors as $error) { + $output->writeln("{$error}"); + } + } + + return self::SUCCESS; + + } catch (\Exception $e) { + $output->writeln("导入过程中发生错误: " . $e->getMessage() . ""); + $output->writeln("文件: " . $e->getFile() . " 行: " . $e->getLine() . ""); + $output->writeln("堆栈: " . $e->getTraceAsString() . ""); + return self::FAILURE; + } + } +} \ No newline at end of file diff --git a/app/command/InitUserPasswordCommand.php b/app/command/InitUserPasswordCommand.php new file mode 100644 index 0000000..bdf76b5 --- /dev/null +++ b/app/command/InitUserPasswordCommand.php @@ -0,0 +1,34 @@ +orWhere('password', '')->get(); + $count = 0; + + foreach ($users as $user) { + if (!empty($user->sn)) { + $user->password = password_hash($user->sn, PASSWORD_DEFAULT); + $user->save(); + $output->writeln("用户ID {$user->id} 密码已初始化"); + $count++; + } else { + $output->writeln("用户ID {$user->id} 的sn字段为空,无法初始化密码"); + } + } + + $output->writeln("共初始化 {$count} 个用户密码"); + return self::SUCCESS; + } +} \ No newline at end of file diff --git a/app/command/TbCommand.php b/app/command/TbCommand.php new file mode 100644 index 0000000..265d24c --- /dev/null +++ b/app/command/TbCommand.php @@ -0,0 +1,36 @@ +all(); + + // foreach ($list as $item) { + // // UserSn::where('user_sn', $item->sn)->update(['user_id' => $item->id]); + // } + + $m = new Emails(); + $m->fenpai(); + + return self::SUCCESS; + } +} \ No newline at end of file diff --git a/app/command/UpdateInquiryLogFromCrmCustomerCommand.php b/app/command/UpdateInquiryLogFromCrmCustomerCommand.php new file mode 100644 index 0000000..06afbc0 --- /dev/null +++ b/app/command/UpdateInquiryLogFromCrmCustomerCommand.php @@ -0,0 +1,144 @@ +setHelp('该命令将crm_customer表的user_id更新到inquiry_log表的recipient字段,将obtain字段更新到t_time字段。') + ->addOption('dry-run', null, InputOption::VALUE_NONE, '仅显示将要执行的更新操作,不实际执行') + ->addOption('limit', 'l', InputOption::VALUE_OPTIONAL, '限制处理的记录数量', 5000); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $isDryRun = $input->getOption('dry-run'); + $limit = (int) $input->getOption('limit'); + + if ($isDryRun) { + $output->writeln('DRY RUN 模式 - 不会实际执行更新操作'); + } + + $output->writeln('开始更新 inquiry_log 表...'); + $output->writeln("处理限制: {$limit} 条记录"); + + try { + // 使用事务确保数据一致性 + DB::transaction(function () use ($output, $isDryRun, $limit) { + // 查询需要更新的记录 + $customers = Db::table('crm_customer') + ->select('id', 'sn', 'user_id', 'obtain','created_at') + ->whereNotNull('sn') + ->where('sn', '<>', '') + ->offset($limit) + ->limit(4000) + ->orderBy('id', 'asc') + ->get(); + + $output->writeln("找到 " . count($customers) . " 条客户记录需要处理"); + + $updatedCount = 0; + $skippedCount = 0; + $defaultTimestamp = strtotime('2025-08-26 00:00:00'); + + $output->writeln("找到 " . count($customers) . " 条客户记录需要处理"); + $step = []; + $updateId = []; + foreach ($customers as $customer) { + $output->writeln("处理客户 ID: {$customer->id}, SN: {$customer->sn}, User ID: {$customer->user_id}, obtain: {$customer->obtain}, created_at: {$customer->created_at}"); + // 查找匹配的 inquiry_log 记录 + $inquiryLog = Db::table('inquiry_log') + ->where('order_no', $customer->sn) + ->first(['id', 'recipient', 't_time']); + + if (!$inquiryLog) { + $step[] = $customer->id; + $output->writeln("跳过客户 ID {$customer->id} (sn: {$customer->sn}) - 未找到对应的 inquiry_log 记录"); + $skippedCount++; + continue; + } + $output->writeln("$inquiryLog->id"); + + // 准备更新数据 + $updateData = []; + + // 更新 recipient 字段 (user_id) + if ($customer->user_id != $inquiryLog->recipient) { + $updateData['recipient'] = $customer->user_id; + $output->writeln("客户 ID {$customer->id}: recipient 将从 {$inquiryLog->recipient} 更新为 {$customer->user_id}"); + } + + if(empty($customer->obtain)&& !empty($customer->user_id)){ + if($customer->user_id == $inquiryLog->recipient){ + $obtain = $inquiryLog->t_time; + }else{ + $obtain = strtotime('2025-08-01 10:00:00'); + $obtain = $customer->created_at > $obtain?$customer->created_at:$obtain; + } + Db::table('crm_customer') + ->where('id', $customer->id) + ->update(['obtain' => $obtain]); + $output->writeln("更新obtain $obtain"); + } + + // 更新 t_time 字段 (obtain) + $newTTime = $customer->obtain > 0 ? $customer->obtain : $defaultTimestamp; + if ($newTTime != $inquiryLog->t_time && !empty($updateData['recipient'])) { + $updateData['t_time'] = $newTTime; + $timeStr = date('Y-m-d H:i:s', $newTTime); + $output->writeln("客户 ID {$customer->id}: t_time 将从 " . ($inquiryLog->t_time ? date('Y-m-d H:i:s', $inquiryLog->t_time) : 'NULL') . " 更新为 {$timeStr}"); + } + if($customer->user_id == 0 && $inquiryLog->recipient != 0){ + $updateData['recipient'] = 0; + $updateData['t_time'] = time(); + $updateData['status'] = 1; + $updateData['r_time'] = time(); + + } + + // 如果有需要更新的字段,执行更新 + if (!empty($updateData)) { + $updateId[] = $customer->id; + if (!$isDryRun) { + Db::table('inquiry_log') + ->where('id', $inquiryLog->id) + ->update($updateData); + } + $updatedCount++; + $output->writeln("已更新 inquiry_log ID {$inquiryLog->id}"); + } else { + $output->writeln("客户 ID {$customer->id} - 无需更新"); + } + } + + $output->writeln("处理完成!"); + $output->writeln("总记录数: " . count($customers) . ""); + $updateIdstr = implode(",", $updateId); + $output->writeln("更新记录数: {$updatedCount},更新id:$updateIdstr"); + $stepstr = implode(",", $step); + $output->writeln("跳过记录数: {$skippedCount},跳过id: {$stepstr}"); + + if ($isDryRun) { + $output->writeln("DRY RUN 模式 - 未实际执行任何更新操作"); + } + }); + + return self::SUCCESS; + } catch (\Exception $e) { + $output->writeln('执行过程中发生错误: ' . $e->getMessage() . ''); + $output->writeln('错误位置: ' . $e->getFile() . ':' . $e->getLine() . ''); + return self::FAILURE; + } + } +} \ No newline at end of file diff --git a/app/command/VehMoneyCommand.php b/app/command/VehMoneyCommand.php new file mode 100644 index 0000000..0485e09 --- /dev/null +++ b/app/command/VehMoneyCommand.php @@ -0,0 +1,29 @@ +action = $action; + } + + public function setMeta(array $meta): void + { + $this->meta = $meta + $this->meta; + } + + /** 模型字段级变更推送(由 Trait 调用) */ + public function pushModelChange(string $scene, $objectId, array $old, array $new): void + { + $keys = array_unique(array_merge(array_keys($old), array_keys($new))); + foreach ($keys as $k) { + $ov = $old[$k] ?? null; + $nv = $new[$k] ?? null; + if ($ov === $nv) continue; + + [$ovMasked, $nvMasked] = $this->maskIfNeeded($k, $ov, $nv); + $this->changes[] = [ + 'scene' => $scene, + 'object_id' => $objectId, + 'field' => $k, + 'old' => $ovMasked, + 'new' => $nvMasked, + ]; + } + } + + private function maskIfNeeded(string $field, $old, $new): array + { + foreach ($this->maskFields as $m) { + if (stripos($field, $m) !== false) { + return [$old===null?null:'***', $new===null?null:'***']; + } + } + if (preg_match('/(mobile|phone|tel|idcard|id_card)/i', $field)) { + $mask = function ($v) { + if ($v === null) return null; + $s = (string)$v; + return mb_substr($s, 0, 3).'****'.mb_substr($s, -4); + }; + return [$mask($old), $mask($new)]; + } + return [$old, $new]; + } +} \ No newline at end of file diff --git a/app/common/controller/AuthController.php b/app/common/controller/AuthController.php new file mode 100644 index 0000000..26323e2 --- /dev/null +++ b/app/common/controller/AuthController.php @@ -0,0 +1,121 @@ +all(); + return $this->success($code); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function feishuScope(Request $request) + { + try { + $result = (new AuthService())->feishuAuthorize($request->get('app_id'), $request->system_code, $request->is_mob); + return $this->success($result); + } catch (\Throwable $e) { + return $this->error($e->getMessage().$e->getLine().$e->getFile()); + } + } + + public function login(Request $request) + { + try { + $data = $request->all(); + + if(empty($data['feishu_code']) || empty($data['app_id'])){ + throw new BusinessException('参数错误'); + } + + $result = (new AuthService())->login($data); + return $this->success($result, '登录成功'); + } catch (\Throwable $e) { + return $this->error($e->getMessage().$e->getLine().$e->getFile()); + } + } + + public function register(Request $request) + { + try { + $data = $this->validate(new UserValidate(), $this->requestData($request), $request->login_type.'_register'); + $result = (new AuthService())->register($data); + return $this->success($result, '注册成功'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function refresh(Request $request) + { + try { + $data = $this->requestData($request); + if (!$data['refresh_token'] || !$data['system_code']) return $this->error('参数缺失', 422); + $result = (new AuthService())->refreshToken($data['refresh_token'], $data['system_code']); + return $this->success($result, '刷新成功'); + } catch (\Throwable $e) { + return $this->error($e->getMessage(), 401); + } + } + + + // public function perm(Request $request) + // { + // try { + // $data = $this->requestData($request); + // if (!$data['access_token'] || !$data['system_code']) return $this->error('参数缺失', 422); + // $result = (new AuthService())->logout($data['access_token'], $data['system_code']); + // return $this->success($result, '已登出'); + // } catch (\Throwable $e) {} + // } + + // public function verify(Request $request) + // { + // try { + // $token = $request->post('access_token') ?: $request->header('authorization'); + // $systemCode = $request->post('system_code'); + // if (!$token || !$systemCode) return $this->error('参数缺失', 422); + // $payload = (new AuthService())->verifyToken($token, $systemCode); + // return $this->success($payload, '验证成功'); + // } catch (\Throwable $e) { + // return $this->error($e->getMessage() ?: '无效或过期的token', 401); + // } + // } + + // public function logout(Request $request) + // { + // $token = $request->post('access_token') ?: $request->header('Authorization'); + // $systemCode = $request->post('system_code') ?: $request->header('system_code'); + // (new AuthService())->logout($token, $systemCode); + // return $this->success([], '已登出'); + // } + + // public function blockToken(Request $request) + // { + // $token = $request->post('token'); + // $systemCode = $request->post('system_code'); + // if (!$token || !$systemCode) return $this->error('参数缺失', 422); + // (new AuthService())->blockToken($token, $systemCode); + // return $this->success([], '已强制停用该token'); + // } +} \ No newline at end of file diff --git a/app/common/controller/BaseController.php b/app/common/controller/BaseController.php new file mode 100644 index 0000000..f617239 --- /dev/null +++ b/app/common/controller/BaseController.php @@ -0,0 +1,92 @@ +scene($scene); + } + // 执行数据验证 + if (!$validator->check($data)) { + // 验证失败,抛出业务异常,异常信息为验证器返回的错误信息 + throw new BusinessException($validator->getError()); + } + // 验证成功,返回原始数据 + return $data; + } + + /** + * 参数处理 + */ + public function requestData(Request $request) + { + return $request->all() + $request->basic_data; + } +} \ No newline at end of file diff --git a/app/common/controller/BrandController.php b/app/common/controller/BrandController.php new file mode 100644 index 0000000..2abe3cb --- /dev/null +++ b/app/common/controller/BrandController.php @@ -0,0 +1,90 @@ +service = new BrandService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + if (!empty($data['logo'])) + $data['logo_full'] = (new OssService)->generatePrivateUrl($data['logo']); + return $data; + } + + /** + * 数据格式化 + * + * @param array $data + * @return array + */ + protected function showFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + if (empty($data['logo'])) + $data['logo_full'] = (new OssService)->generatePrivateUrl($data['logo']); + return $data; + } + + /** + * 抓取品牌系列 + * @param Request $request + * @return \support\Response + */ + public function crawlSeries(Request $request){ + try { + $result = $this->service->crawlSeries($request); + if($result){ + return $this->success($result,'成功'); + }else{ + return $this->error('抓取任务提交失败'); + } + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 创建被抓取且选中的品牌系列 + * @param Request $request + * @return \support\Response + */ + public function crawlSeriesCreate(Request $request){ + try { + $result = $this->service->crawlSeriesCreate($request); + if($result){ + return $this->success([],$result); + }else{ + return $this->error('创建失败'); + } + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/common/controller/BrandSeriesController.php b/app/common/controller/BrandSeriesController.php new file mode 100644 index 0000000..3cd32d3 --- /dev/null +++ b/app/common/controller/BrandSeriesController.php @@ -0,0 +1,121 @@ +service = new BrandSeriesService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + return $data; + } + + /** + * 数据格式化 + * + * @param array $data + * @return array + */ + protected function showFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + return $data; + } + + /** + * 抓取系列车型 + * @param Request $request + * @return \support\Response + */ + public function crawlModels(Request $request){ + try { + $result = $this->service->crawlModel($request); + if($result){ + return $this->success($result,'抓取成功'); + }else{ + return $this->error('抓取任务失败'); + } + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 保存抓取的系列车型 + * @param Request $request + * @return \support\Response + */ + public function crawlModelCreate(Request $request){ + try { + $result = $this->service->crawlModelCreate($request); + if($result){ + return $this->success($result,'成功'); + }else{ + return $this->error('创建失败'); + } + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 关键词抓取品牌、车系、车型 + * @param Request $request + * @return \support\Response + */ + public function crawlKeyword(Request $request){ + try { + $result = $this->service->crawlKeyword($request); + if($result){ + return $this->success($result,'抓取成功'); + }else{ + return $this->error('抓取任务失败'); + } + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 保存抓取的系列车型 + * @param Request $request + * @return \support\Response + */ + public function crawlKeywordCreate(Request $request){ + try { + $result = $this->service->crawlKeywordCreate($request); + if($result){ + return $this->success($result,'成功'); + }else{ + return $this->error('创建失败'); + } + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/common/controller/FinPaymentApplyController.php b/app/common/controller/FinPaymentApplyController.php new file mode 100644 index 0000000..98c49c2 --- /dev/null +++ b/app/common/controller/FinPaymentApplyController.php @@ -0,0 +1,181 @@ +service = new FinPaymentApplyService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['doc_user_id'])) //内勤 + $data['doc_user_name'] = User::where('id', $data['doc_user_id'])->value('name')??''; + if (isset($data['sales_user_id'])) //业务员 + $data['sales_user_name'] = User::where('id', $data['doc_user_id'])->value('name')??''; + if (isset($data['corp_id'])) //公司(渠道) + $data['corp_name'] = SysDictItem::where('id', $data['corp_id'])->value('item_label')??''; + if (isset($data['account_id'])){ //收款账号 + $data['account_name'] = SysAccount::where('id', $data['account_id'])->value('account_name')??''; + $data['account_short_desc'] = SysAccount::where('id', $data['account_id'])->value('short_desc')??''; + } + if (isset($data['country_id'])) //付款来源国家 + $data['country_name'] = Country::where('id', $data['country_id'])->value('cn_addr')??''; + if (isset($data['currency_id'])) //付款币种 + $data['currency_name'] = SysCurrency::where('id', $data['currency_id'])->value('currency_name')??''; + $OssService = new OssService(); + if (!empty($data['submit_files'])){ + $data['submit_files'] = json_decode($data['submit_files'], true); + foreach ($data['submit_files'] as $key => $value) { + $data['submit_files'][$key] = [ + 'file_src' => $value['file_src'], + 'file_full' => $OssService->generatePrivateUrl($value['file_src']), + 'file_name' => $value['file_name'], + ]; + } + }else{ + $data['submit_files'] = []; + } + + if (!empty($data['voucher_file'])){ + $data['voucher_file'] = json_decode($data['voucher_file'], true); + foreach ($data['voucher_file'] as $key => $value) { + $data['voucher_file'][$key] = [ + 'file_src' => $value['file_src'], + 'file_full' => $OssService->generatePrivateUrl($value['file_src']), + 'file_name' => $value['file_name'], + ]; + } + }else{ + $data['voucher_file'] = []; + } + + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + if (isset($data['claimed_at'])){ + $data['enter_file_last'] = date('Y-m-d H:i:s', ($data['claimed_at']+86400)); //入账资料最后提交时间 + $data['claimed_at'] = date('Y-m-d H:i:s', $data['claimed_at']); //认领时间 + } + if (isset($data['id'])){ + $finPaymentStock = FinPaymentStock::where('payment_apply_id', $data['id'])->get(); + $stock = []; + $stock_order_no = []; + foreach ($finPaymentStock as $v) { + $stock[$v['pi_no']]['pi_no'] = $v['pi_no']; + $stock[$v['pi_no']]['stock_id'][] = [$v['stock_id']]; + $stockOrders = StockOrders::where('id', $v['stock_id'])->first(); + $stock_order_no[] = $stockOrders['order_no']; + } + $data['stock'] = array_values($stock); + $data['stock_order_no'] = $stock_order_no; + } + + return $data; + } + + /** + * 数据格式化 + * + * @param array $data + * @return array + */ + public function showFormat($data) + { + if (isset($data['doc_user_id'])) //内勤 + $data['doc_user_name'] = User::where('id', $data['doc_user_id'])->value('name')??''; + if (isset($data['sales_user_id'])) //业务员 + $data['sales_user_name'] = User::where('id', $data['doc_user_id'])->value('name')??''; + if (isset($data['corp_id'])) //公司(渠道) + $data['corp_name'] = SysDictItem::where('id', $data['corp_id'])->value('item_label')??''; + if (isset($data['account_id'])){ //收款账号 + $data['account_name'] = SysAccount::where('id', $data['account_id'])->value('account_name')??''; + $data['account_short_desc'] = SysAccount::where('id', $data['account_id'])->value('short_desc')??''; + } + if (isset($data['country_id'])) //付款来源国家 + $data['country_name'] = Country::where('id', $data['country_id'])->value('cn_addr')??''; + if (isset($data['currency_id'])) //付款币种 + $data['currency_name'] = SysCurrency::where('id', $data['currency_id'])->value('currency_name')??''; + $OssService = new OssService(); + if (!empty($data['submit_files'])){ + $data['submit_files'] = json_decode($data['submit_files'], true); + foreach ($data['submit_files'] as $key => $value) { + $data['submit_files'][$key] = [ + 'file_src' => $value['file_src'], + 'file_full' => $OssService->generatePrivateUrl($value['file_src']), + 'file_name' => $value['file_name'], + ]; + } + }else{ + $data['submit_files'] = []; + } + + if (!empty($data['voucher_file'])){ + $data['voucher_file'] = json_decode($data['voucher_file'], true); + foreach ($data['voucher_file'] as $key => $value) { + $data['voucher_file'][$key] = [ + 'file_src' => $value['file_src'], + 'file_full' => $OssService->generatePrivateUrl($value['file_src']), + 'file_name' => $value['file_name'], + ]; + } + }else{ + $data['voucher_file'] = []; + } + + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + + $finPaymentStock = FinPaymentStock::where('payment_apply_id', $data['id'])->get(); + $stock = []; + foreach ($finPaymentStock as $v) { + $stock[$v['pi_no']]['pi_no'] = $v['pi_no']; + $stock[$v['pi_no']]['stock_id'][] = [$v['stock_id']]; + } + $data['stock'] = array_values($stock); + return $data; + } + + /** + * 作废 + * @param Request $request + * @return \support\Response + */ + public function delete(Request $request) + { + try { + $id = $request->input('id'); + $raw = $this->service->delete($id); + return $this->success($raw); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/common/controller/FinReceiptController.php b/app/common/controller/FinReceiptController.php new file mode 100644 index 0000000..4c73b7a --- /dev/null +++ b/app/common/controller/FinReceiptController.php @@ -0,0 +1,195 @@ +service = new FinReceiptService(); + } + + /** + * 确认入账 + * @param Request $request + * @return \support\Response + */ + public function credit(Request $request) + { + try { + $id = $request->input('id'); + $exchange_rate = $request->input('exchange_rate'); + if(empty($exchange_rate) || !is_numeric($exchange_rate)){ + return $this->error('请输入汇率'); + } + $raw = $this->service->credit($id,$exchange_rate); + return $this->success($raw); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function show(Request $request) + { + try { + $raw = $this->service->serviceShow($request); + $data = $this->transformData($raw, method_exists($this, 'showFormat') + ? [$this, 'showFormat'] + : null); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 更新 + * + * @param Request $request 请求对象 + * @return mixed 返回更新成功的数据或信息 + */ + public function payment(Request $request) + { + try { + $data = $this->validate($this->service->getValidator(), $request->post(), 'update'); + $res = $this->service->serviceUpdatePayment($request, $data); + return $this->success([], '更新成功'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['created_at'])){ + $data['claim_timeout'] = (($data['created_at'] < (time()-86400)) && empty($data['payment_apply_id'])) ?1:0; + $data['claim_timeout_time'] = date('Y-m-d H:i:s', $data['created_at']+86400); + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + } + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + if (isset($data['arrived_at'])) + $data['arrived_at'] = date('Y-m-d H:i:s', $data['arrived_at']); + if (isset($data['received_at'])){ + $data['received_at'] = date('Y-m-d H:i:s', $data['received_at']); + $voucher_no_timeout_hour = redis::get('voucher_no_timeout_hour'); + if(!empty($voucher_no_timeout_hour)){ + $data['voucher_no_timeout_time'] = date('Y-m-d H:i:s', time()+(60*60*$voucher_no_timeout_hour)); + } + } + if (isset($data['account_id'])) { //收款账号 + $data['account_name'] = SysAccount::where('id', $data['account_id'])->value('account_name')??''; + $data['account_short_desc'] = SysAccount::where('id', $data['account_id'])->value('short_desc')??''; + } + if (isset($data['country_id'])) //付款来源国家 + $data['country_name'] = Country::where('id', $data['country_id'])->value('cn_addr')??''; + if (isset($data['currency_id'])) //付款币种 + $data['currency_name'] = SysCurrency::where('id', $data['currency_id'])->value('currency_name')??''; + + $OssService = new OssService(); + if (!empty($data['fx_receipt_file'])){ + $data['fx_receipt_file'] = json_decode($data['fx_receipt_file'], true); + foreach ($data['fx_receipt_file'] as $key => $value) { + $data['fx_receipt_file'][$key] = [ + 'file_src' => $value['file_src'], + 'file_full' => $OssService->generatePrivateUrl($value['file_src']), + 'file_name' => $value['file_name'], + ]; + } + }else{ + $data['fx_receipt_file'] = []; + } + return $data; + } + + /** + * 数据格式化 + * + * @param array $data + * @return array + */ + protected function showFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + if (isset($data['arrived_at'])) + $data['arrived_at'] = date('Y-m-d H:i:s', $data['arrived_at']); + if (isset($data['received_at'])) + $data['received_at'] = date('Y-m-d H:i:s', $data['received_at']); + if (isset($data['account_id'])) { //收款账号 + $data['account_name'] = SysAccount::where('id', $data['account_id'])->value('account_name')??''; + $data['account_short_desc'] = SysAccount::where('id', $data['account_id'])->value('short_desc')??''; + } + if (isset($data['country_id'])) //付款来源国家 + $data['country_name'] = Country::where('id', $data['country_id'])->value('cn_addr')??''; + if (isset($data['currency_id'])) //付款币种 + $data['currency_name'] = SysCurrency::where('id', $data['currency_id'])->value('currency_name')??''; + + $OssService = new OssService(); + if (!empty($data['fx_receipt_file'])){ + $data['fx_receipt_file'] = json_decode($data['fx_receipt_file'], true); + foreach ($data['fx_receipt_file'] as $key => $value) { + $data['fx_receipt_file'][$key] = [ + 'file_src' => $value['file_src'], + 'file_full' => $OssService->generatePrivateUrl($value['file_src']), + 'file_name' => $value['file_name'], + ]; + } + }else{ + $data['fx_receipt_file'] = []; + } + + //查询关联的付款记录 + if (!empty($data['payment_apply_id'])) { + $paymentApply = FinPaymentApply::where('id', $data['payment_apply_id'])->first(); + if(!$paymentApply) + return $data; + $paymentApply = $paymentApply->toArray(); + $paymentApply['created_at'] = strtotime($paymentApply['created_at']); + $paymentApply['updated_at'] = strtotime($paymentApply['updated_at']); + $paymentApply = (new FinPaymentApplyController())->showFormat($paymentApply); + $data['payment_apply'] = $paymentApply; + } + return $data; + } + + /** + * 收款记录统计 + * @param Request $request + * @return \support\Response + */ + public function statistics(Request $request) + { + try { + $raw = $this->service->statistics(); + return $this->success($raw); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/common/controller/FinReceiptHisController.php b/app/common/controller/FinReceiptHisController.php new file mode 100644 index 0000000..7adcf0d --- /dev/null +++ b/app/common/controller/FinReceiptHisController.php @@ -0,0 +1,98 @@ +service = new FinReceiptHisService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + if (isset($data['arrived_at'])) + $data['arrived_at'] = date('Y-m-d H:i:s', $data['arrived_at']); + if (isset($data['received_at'])) + $data['received_at'] = date('Y-m-d H:i:s', $data['received_at']); + if (isset($data['account_id'])) { //收款账号 + $data['account_name'] = SysAccount::where('id', $data['account_id'])->value('account_name')??''; + $data['account_short_desc'] = SysAccount::where('id', $data['account_id'])->value('short_desc')??''; + } + if (isset($data['country_id'])) //付款来源国家 + $data['country_name'] = Country::where('id', $data['country_id'])->value('cn_addr')??''; + if (isset($data['currency_id'])) //付款币种 + $data['currency_name'] = SysCurrency::where('id', $data['currency_id'])->value('currency_name')??''; + if (!empty($data['fx_receipt_file'])){ + $data['fx_receipt_file_full'] = (new OssService)->generatePrivateUrl($data['fx_receipt_file']); + }elseif (isset($data['fx_receipt_file'])){ + $data['fx_receipt_file_full'] = ''; + } + + if (isset($data['created_by'])) + $data['created_by'] = User::where('id', $data['created_by'])->value('name')??''; + if (isset($data['updated_by'])) + $data['updated_by'] = User::where('id', $data['updated_by'])->value('name')??''; + return $data; + } + + /** + * 数据格式化 + * + * @param array $data + * @return array + */ + protected function showFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + if (isset($data['arrived_at'])) + $data['arrived_at'] = date('Y-m-d H:i:s', $data['arrived_at']); + if (isset($data['received_at'])) + $data['received_at'] = date('Y-m-d H:i:s', $data['received_at']); + if (isset($data['account_id'])){ //收款账号 + $data['account_name'] = SysAccount::where('id', $data['account_id'])->value('account_name')??''; + $data['account_short_desc'] = SysAccount::where('id', $data['account_id'])->value('short_desc')??''; + } + if (isset($data['country_id'])) //付款来源国家 + $data['country_name'] = Country::where('id', $data['country_id'])->value('cn_addr')??''; + if (isset($data['currency_id'])) //付款币种 + $data['currency_name'] = SysCurrency::where('id', $data['currency_id'])->value('currency_name')??''; + if (!empty($data['fx_receipt_file'])){ + $data['fx_receipt_file_full'] = (new OssService)->generatePrivateUrl($data['fx_receipt_file']); + }elseif (isset($data['fx_receipt_file'])){ + $data['fx_receipt_file_full'] = ''; + } + + if (isset($data['created_by'])) + $data['created_by'] = User::where('id', $data['created_by'])->value('name')??''; + if (isset($data['updated_by'])) + $data['updated_by'] = User::where('id', $data['updated_by'])->value('name')??''; + return $data; + } +} \ No newline at end of file diff --git a/app/common/controller/InvRequisitionApproveController.php b/app/common/controller/InvRequisitionApproveController.php new file mode 100644 index 0000000..75d419d --- /dev/null +++ b/app/common/controller/InvRequisitionApproveController.php @@ -0,0 +1,33 @@ +service = new InvRequisitionApproveService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/InvRequisitionAttachmentController.php b/app/common/controller/InvRequisitionAttachmentController.php new file mode 100644 index 0000000..8ba33e9 --- /dev/null +++ b/app/common/controller/InvRequisitionAttachmentController.php @@ -0,0 +1,33 @@ +service = new InvRequisitionAttachmentService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/InvRequisitionItemController.php b/app/common/controller/InvRequisitionItemController.php new file mode 100644 index 0000000..bf1897c --- /dev/null +++ b/app/common/controller/InvRequisitionItemController.php @@ -0,0 +1,33 @@ +service = new InvRequisitionItemService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/InvRequisitionItemReturnAttachmentController.php b/app/common/controller/InvRequisitionItemReturnAttachmentController.php new file mode 100644 index 0000000..8580b28 --- /dev/null +++ b/app/common/controller/InvRequisitionItemReturnAttachmentController.php @@ -0,0 +1,33 @@ +service = new InvRequisitionItemReturnAttachmentService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/InvRequisitionItemReturnController.php b/app/common/controller/InvRequisitionItemReturnController.php new file mode 100644 index 0000000..7623719 --- /dev/null +++ b/app/common/controller/InvRequisitionItemReturnController.php @@ -0,0 +1,33 @@ +service = new InvRequisitionItemReturnService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/MessageNotificationsController.php b/app/common/controller/MessageNotificationsController.php new file mode 100644 index 0000000..19341fc --- /dev/null +++ b/app/common/controller/MessageNotificationsController.php @@ -0,0 +1,52 @@ +service = new MessageNotificationsService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + return $data; + } + + protected function showFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + return $data; + } + + + public function push(Request $request) + { + try { + $data = $this->validate($this->service->getValidator(), $request->post(), 'store'); + $res = $this->service->servicePush($request, $data); + return $this->success($res, '推送成功'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/common/controller/MessageUserStatusController.php b/app/common/controller/MessageUserStatusController.php new file mode 100644 index 0000000..9a896a1 --- /dev/null +++ b/app/common/controller/MessageUserStatusController.php @@ -0,0 +1,95 @@ +service = new MessageUserStatusService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['read_at']) && $data['read_at'] !== null) { + $data['read_at'] = date('Y-m-d H:i:s', $data['read_at']); + } else { + $data['read_at'] = ' - '; + } + + if (isset($data['message'])) + $data['message']['created_at'] = date('Y-m-d H:i:s', $data['message']['created_at']); + return $data; + } + + protected function showFormat($data) + { + if (isset($data['read_at']) && $data['read_at'] !== null) { + $data['read_at'] = date('Y-m-d H:i:s', $data['read_at']); + } else { + $data['read_at'] = ' - '; + } + + if (isset($data['message'])) + $data['message']['created_at'] = date('Y-m-d H:i:s', $data['message']['created_at']); + return $data; + } + + protected function unReadFormat($data) + { + foreach ($data as &$item) { + if (isset($item['read_at']) && $item['read_at'] !== null) { + $item['read_at'] = date('Y-m-d H:i:s', $item['read_at']); + } else { + $item['read_at'] = ' - '; + } + + if (isset($item['message'])) { + $item['message']['created_at'] = date('Y-m-d H:i:s', $item['message']['created_at']); + } + } + return $data; + } + + public function read(Request $request) + { + try { + $data = $this->validate($this->service->getValidator(), $request->post(), 'read'); + $this->service->serviceRead($request, $data); + return $this->success([], '操作成功'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function listUnRead(Request $request) + { + try { + $raw = $this->service->serviceListUnRead($request); + $data = $this->transformData($raw, method_exists($this, 'unReadFormat') + ? [$this, 'unReadFormat'] + : null); + $data = [ + 'list' => $data, + 'counts' => count($data) + ]; + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/common/controller/NotifyController.php b/app/common/controller/NotifyController.php new file mode 100644 index 0000000..9e4fffb --- /dev/null +++ b/app/common/controller/NotifyController.php @@ -0,0 +1,136 @@ +post(); + + // 解析数据 + // $param = json_decode($param['event'], true); + $orderSn = $param['event']['action']['value']; + if (strpos($orderSn, '"') !== false) { + $orderSn = str_replace('"', '', $orderSn); + } + + // 处理询单&客户线索信息 + $openId = $param['event']['operator']['open_id']; + + // 获取询单 + $order = InquiryLog::with(['channelInfo', 'countryInfo', 'enter'])->where('order_no', $orderSn)->first(); + if (!$order) { + return $this->error('未找到询单记录'); + } + + // 获取客户xians + $customer = CrmCustomer::where('sn', $orderSn)->first(); + if (!$customer) { + return $this->error('未找到客户线索'); + } + + // 获取用户 + $uinfo = User::where('open_id', $openId)->first(); + + // 更新客户线索 + $customer->user_id = $uinfo->id; + + // 更新询单数据 + $order->recipient = $uinfo->id; + $order->status = 2; + $order->t_time = time(); + + // 保存 + if (!$order->save() || !$customer->save()) { + return $this->error('跟踪失败'); + } + + // 获取飞书消息模板 + $feishuMessage = new FeishuMessage(); + $content = $feishuMessage->groupUpdateTemplates( + (new InquiryLogService())->qualityName($order->quality), + $order->order_no, + $order->channelInfo ? $order->channelInfo->name : '', + $order->countryInfo ? $order->countryInfo->cn_addr : '', + $order->enter ? $order->enter->name : '', + $order->r_time ? date('Y-m-d H:i:s', $order->r_time) : '', + $order->t_time ? date('Y-m-d H:i:s', $order->t_time) : '' + ); + + // 组装飞书消息模板 + $data = '{ + "toast": { + "type": "success", + "content": "操作成功" + }, + "card": { + "type": "raw", + "data": '.$content.' + } + }'; + + // 响应消息 + return new Response(200, ['Content-Type' => 'application/json'], $data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function error($msg = '') + { + $data = '{ + "toast": { + "type": "error", + "content": "'.$msg.'" + } + }'; + return new Response(200, ['Content-Type' => 'application/json'], $data); + } + + /** + * 飞书事件 + * @param Request $request + * @return Response + */ + public function feishuEvent(Request $request) + { + try { + $data = $request->post(); + + var_dump($data); + + return json($data); + } catch (\Throwable $e) { + return json($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/common/controller/PresetController.php b/app/common/controller/PresetController.php new file mode 100644 index 0000000..bd92d2f --- /dev/null +++ b/app/common/controller/PresetController.php @@ -0,0 +1,233 @@ +getList(); + return $this->success($result, '获取公司列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + + + public function feishuApp(Request $request) + { + try { + $corpId = $request->get('corp_id'); + $systemId = $request->get('system_id'); + $result = (new CorpFeishuAppService())->getFeishuApp($corpId, $systemId); + return $this->success($result, '获取飞书应用信息'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + + + public function systems() + { + try { + $result = (new SystemsService())->getList(); + return $this->success($result, '获取系统列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + + public function users(Request $request) + { + try { + $result = (new UserService())->getList($request); + return $this->success($result, '获取用户列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + + public function roles() + { + try { + $result = (new RoleService)->getList(); + return $this->success($result, '获取角色列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function currency() + { + try { + $result = (new SysCurrencyService)->getList(); + return $this->success($result, '获取货币列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function brand() + { + try { + $result = (new BrandService)->getList(); + return $this->success($result, '获取品牌列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function series(Request $request) + { + try { + $params = $request->all(); + $result = (new BrandSeriesService)->getList($params); + return $this->success($result, '获取车系列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function account() + { + try { + $result = (new SysAccountService)->getList(); + return $this->success($result, '获取账号列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function dict() + { + try { + $result = (new SysDictService)->getList(); + return $this->success($result, '获取字典列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function dictItem(Request $request) + { + try { + $params = $request->all(); + $result = (new SysDictItemService)->getList($params); + return $this->success($result, '获取字典项列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function port(Request $request) + { + try { + $params = $request->all(); + $result = (new SysPortService)->getList($params); + return $this->success($result, '获取港口列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function color(Request $request) + { + try { + $params = $request->all(); + $result = (new VehColorService)->getList($params); + return $this->success($result, '获取颜色列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function modification() + { + try { + $result = (new VehModificationService)->getList(); + return $this->success($result, '获取货币列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + + public function cacheClear() + { + try { + Cache::clear(); + return $this->success([], '操作成功'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function menus(Request $request) + { + try { + $uid = $request->user->uid; + $systemCode = $request->system_code; + $permService = new PermService(); + $menuTree = $permService->getMenuTree($uid, $systemCode); + $permissionCodes = $permService->getPermissionCodes($uid, $systemCode); + $result = [ + 'menu' => $menuTree, + 'permissions' => $permissionCodes + ]; + return $this->success($result, '获取菜单权限'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + + public function userGroup(Request $request) + { + try { + $result = (new UserGroupService)->userGroup($request); + return $this->success($result, '获取分组'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + + public function userGroupScene(Request $request) + { + try { + $result = (new UserGroupService)->userGroupScene($request); + return $this->success($result, '获取分组场景'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/common/controller/PurchaseOrderItemsController.php b/app/common/controller/PurchaseOrderItemsController.php new file mode 100644 index 0000000..e602109 --- /dev/null +++ b/app/common/controller/PurchaseOrderItemsController.php @@ -0,0 +1,33 @@ +service = new PurchaseOrderItemsService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/PurchaseOrdersController.php b/app/common/controller/PurchaseOrdersController.php new file mode 100644 index 0000000..5b86f47 --- /dev/null +++ b/app/common/controller/PurchaseOrdersController.php @@ -0,0 +1,137 @@ +service = new PurchaseOrdersService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['records']) && count($data['records']) > 0) { + foreach ($data['records'] as &$record) { + $contractFile = $record['contract_file'] ? explode(',', $record['contract_file']) : []; + $contractFileUrl = []; + if (count($contractFile) > 0) { + foreach ($contractFile as $file) { + $contractFileUrl[] = (new \app\common\service\OssService)->generatePrivateUrl($file); + } + } + $result = [ + 'id' => $record['id'], + 'order_no' => $record['order_no'], + 'pi_no' => $record['pi_no'], + 'buyer_id' => $record['buyer_id'], + 'buyer_name' => $record['buyer_name'], + 'contract_file' => $contractFile, + 'contract_file_url' => $contractFileUrl, + 'status' => $record['status'], + 'status_text' => array_flip(\app\common\service\SysDictService::pluckKeyValueByCode('purchase_status'))[$record['status']], + 'remark' => $record['remark'], + 'created_at' => date('Y-m-d H:i:s', $record['created_at']), + 'updated_at' => date('Y-m-d H:i:s', $record['updated_at']), + ]; + $record = $result; + } + unset($record); + } + return $data; + } + + /** + * 详情页数据格式化 + * + * @param array $data + * @return array + */ + protected function showFormat($data) + { + $contractFile = $data['contract_file'] ? explode(',', $data['contract_file']) : []; + $contractFileUrl = []; + if (count($contractFile) > 0) { + foreach ($contractFile as $file) { + $contractFileUrl[] = (new \app\common\service\OssService)->generatePrivateUrl($file); + } + } + $result = [ + 'id' => $data['id'], + 'order_no' => $data['order_no'], + 'pi_no' => $data['pi_no'], + 'buyer_id' => $data['buyer_id'], + 'buyer_name' => $data['buyer_name'], + 'contract_file' => $contractFile, + 'contract_file_url' => $contractFileUrl, + 'status' => $data['status'], + 'remark' => $data['remark'] + ]; + $temps = []; + if (isset($data['items']) && count($data['items']) > 0) { + foreach ($data['items'] as $record) { + if (!in_array($record['stockOrder']['id'], array_keys($temps))) { + $temps[$record['stockOrder']['id']] = [ + 'id' => $record['stockOrder']['id'], + 'order_no' => $record['stockOrder']['order_no'], + 'salesperson_snapshot' => $record['stockOrder']['salesperson_snapshot'], + 'biz_date' => date('Y-m-d', $record['stockOrder']['biz_date']) + ]; + } + $temps[$record['stockOrder']['id']]['stock_order_items'][] =[ + 'id' => $record['stockOrderItems']['id'], + 'model_snapshot' => $record['stockOrderItems']['model_snapshot'], + 'unique_id' => $record['stockOrderItems']['unique_id'], + ]; + } + } + $result['stock_order'] = array_values($temps); + return $result; + } + + /** + * 前置查询处理 + * + * @param Request $request + * @return array + */ + public function preQueryData(Request $request) + { + try { + $data = $this->service->preQueryData($request->all()); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 重提 + * + * @param Request $request + * @return mixed + */ + public function resubmit(Request $request) + { + try { + $raw = $this->service->serviceResubmit($request, $request->all()); + return $this->success($raw); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/common/controller/ResourceController.php b/app/common/controller/ResourceController.php new file mode 100644 index 0000000..5fd35b9 --- /dev/null +++ b/app/common/controller/ResourceController.php @@ -0,0 +1,82 @@ +service = new ResourceService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function allFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + return $data; + } + + /** + * 资源树形结构 + */ + public function listTree(Request $request) + { + try { + $raw = $this->service->serviceListAll($request); + $data = $this->transformData($raw, method_exists($this, 'allFormat') + ? [$this, 'allFormat'] + : null); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 菜单 + */ + public function menuList(Request $request) + { + try { + $raw = $this->service->serviceMenuList($request); + $data = $this->transformData($raw, method_exists($this, 'allFormat') + ? [$this, 'allFormat'] + : null); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 菜单 + */ + public function menuTreeList(Request $request) + { + try { + $raw = $this->service->serviceMenuTreeList($request); + $data = $this->transformData($raw, method_exists($this, 'allFormat') + ? [$this, 'allFormat'] + : null); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/common/controller/StockOrdersFilesController.php b/app/common/controller/StockOrdersFilesController.php new file mode 100644 index 0000000..dfb6a23 --- /dev/null +++ b/app/common/controller/StockOrdersFilesController.php @@ -0,0 +1,33 @@ +service = new StockOrdersFilesService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/SysOpLogController.php b/app/common/controller/SysOpLogController.php new file mode 100644 index 0000000..525232e --- /dev/null +++ b/app/common/controller/SysOpLogController.php @@ -0,0 +1,33 @@ +service = new SysOpLogService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + return $data; + } +} \ No newline at end of file diff --git a/app/common/controller/SysPortController.php b/app/common/controller/SysPortController.php new file mode 100644 index 0000000..a5cc59d --- /dev/null +++ b/app/common/controller/SysPortController.php @@ -0,0 +1,53 @@ +service = new SysPortService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + if (isset($data['country_id'])) + if (isset($data['country_id'])) + $data['country_name'] = Country::where('id', $data['country_id'])->value('cn_addr'); + return $data; + } + + /** + * 数据格式化 + * + * @param array $data + * @return array + */ + protected function showFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + $data['country_name'] = Country::where('id', $data['country_id'])->value('cn_addr'); + return $data; + } +} \ No newline at end of file diff --git a/app/common/controller/SysUpdateLogController.php b/app/common/controller/SysUpdateLogController.php new file mode 100644 index 0000000..56a3218 --- /dev/null +++ b/app/common/controller/SysUpdateLogController.php @@ -0,0 +1,35 @@ +service = new SysUpdateLogService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['update_time'])) + $data['update_time'] = date('Y-m-d H:i:s', $data['update_time']); + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + return $data; + } +} \ No newline at end of file diff --git a/app/common/controller/UploadController.php b/app/common/controller/UploadController.php new file mode 100644 index 0000000..800090a --- /dev/null +++ b/app/common/controller/UploadController.php @@ -0,0 +1,302 @@ +input('scene', 'image'); + $file = $request->file('file'); + if ($file && $file->isValid()) { + $fileName = md5($file->getUploadName() . time()).'.'.$file->getUploadExtension(); + $pathUrl = '/uploads/'.$scene.'/'.date('Ymd').'/'.$fileName; + $fullPathUrl = public_path() . $pathUrl; + $file->move($fullPathUrl); + + $data = [ + 'domain' => getenv('MAIN_DOMAIN'), + 'file_url' => $pathUrl, + 'full_url' => getenv('MAIN_DOMAIN') . $pathUrl, + 'scene' => $scene, + ]; + + // 记录文件信息到数据库 + try { + $uploadedFilesService = new UploadedFilesService(); + $partition = $scene; + $category = $request->input('category', 'general'); + + $fileInfo = [ + 'file_name' => basename($fileName), + 'file_path' => $pathUrl, + 'file_size' => $file->getSize(), + 'file_type' => $file->getUploadMimeType(), + 'original_name' => $file->getUploadName(), + ]; + + $recordResult = $uploadedFilesService->recordFileInfo($fileInfo, $request, $partition, $category); + $data['file_id'] = $recordResult['id']; + $data['partition'] = $partition; + $data['category'] = $category; + } catch (\Throwable $e) { + // 记录失败不影响上传结果,只记录日志 + Log::error('记录文件信息失败: ' . $e->getMessage()); + } + + return $this->success($data, 'upload success'); + } + return $this->error('file not found'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + // 上传单文件附件 + public function uploadFile(Request $request) + { + try { + $scene = $request->input('scene'); + $file = $request->file('file'); + + if (!$file || !$file->isValid()) { + return $this->error('上传失败'); + } + if (empty($scene)) { +// return $this->error('上传场景不能为空'); + $sceneUrl = ''; + }else{ + $sceneUrl = $scene . '/'; + } + $originalName = $file->getUploadName(); + + $ossservice = new OssService(); + $mimes = $ossservice->getMimeTypes(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'zip', 'rar']); + + $result = Storage::adapter('oss') + ->path(config('oss.oss_main_dir') . $sceneUrl . date('Ymd')) + ->size(1024*1024*5) + ->extYes($mimes) + ->upload($file); + + + $data = [ + 'original_name' => $originalName, + 'file_name' => $result->file_name, + 'file_url' => $ossservice->generatePrivateUrl($result->file_name) + ]; + + // 记录文件信息到数据库 + $category = $request->input('category', 'general'); + $res = $this->recordFileInfo($result,$request,$file,$scene,$category); + $data = array_merge($data, $res); + + return $this->success($data); + }catch (\Throwable $e){ + return $this->error($e->getMessage()); + } + } + + // 上传单文件图片 + public function uploadImage(Request $request) + { + try { + $scene = $request->input('scene'); + $file = $request->file('image'); + + if (!$file || !$file->isValid()) { + return $this->error('上传失败'); + } + if (empty($scene)) { +// return $this->error('上传场景不能为空'); + $sceneUrl = ''; + }else{ + $sceneUrl = $scene . '/'; + } + + $result = Storage::adapter('oss') + ->path(config('oss.oss_main_dir') . $sceneUrl . date('Ymd')) + ->size(1024*1024*5) + ->extYes(['image/jpeg','image/png']) + ->extNo(['image/gif']) + ->upload($file); + + $ossservice = new OssService(); + + $data = [ + 'href' => $result->file_name, + 'alt' => '', + 'url' => $ossservice->generatePrivateUrl($result->file_name) + ]; + + // 记录文件信息到数据库 + $category = $request->input('category', 'general'); + $res = $this->recordFileInfo($result,$request,$file,$scene,$category); + $data = array_merge($data, $res); + + return json(['errno' => 0, 'data' => $data]); +// return $this->success($data); + } catch (\Throwable $e) { + return json(['errno' => 1, 'message' => $e->getMessage()]); +// return $this->error($e->getMessage()); + } + } + + public function recordFileInfo($result, $request, $file, $scene, $category) + { + try { + $uploadedFilesService = new UploadedFilesService(); + $partition = $scene; + + $fileInfo = [ + 'file_name' => basename($result->file_name), + 'file_path' => $result->file_name, // OSS存储路径 + 'file_size' => $file->getSize(), + 'file_type' => $file->getUploadMimeType(), + 'original_name' => $file->getUploadName(), + ]; + + $partition = !empty($partition)?$partition:'other'; + $category = !empty($category)?$category:'other'; + $recordResult = $uploadedFilesService->recordFileInfo($fileInfo, $request, $partition, $category); + $data = []; + $data['file_id'] = $recordResult['id']; + $data['partition'] = $partition; + $data['category'] = $category; + return $data; + } catch (\Throwable $e) { + // 记录失败不影响上传结果,只记录日志 + Log::error('记录文件信息失败: ' . $e->getMessage()); + return []; + } + } + + /** + * 获取文件信息 + * + * @param Request $request + * @return \support\Response + */ + public function getFileInfo(Request $request) + { + try { + $fileId = $request->input('id'); + + if (!$fileId) { + return $this->error('文件ID不能为空'); + } + + $uploadedFilesService = new UploadedFilesService(); + $fileInfo = $uploadedFilesService->getFileInfo($fileId); + + if (!$fileInfo) { + return $this->error('文件不存在'); + } + + return $this->success($fileInfo); + } catch (\Throwable $e) { + return $this->error('获取文件信息失败: ' . $e->getMessage()); + } + } + + /** + * 删除文件 + * + * @param Request $request + * @return \support\Response + */ + public function deleteFile(Request $request) + { + try { + $fileId = $request->input('id'); + $path = $request->input('path'); + + $uploadedFilesService = new UploadedFilesService(); + if(!empty($path) && empty($fileId)){ + $where = ['file_path' => $path]; + $file = $uploadedFilesService->getFile($where); + $fileId = $file->id; + }elseif(empty($path) && !empty($fileId)){ + $where = ['id' => $fileId]; + $file = $uploadedFilesService->getFile($where); + $path = $file->file_path; + } + if (!$fileId) { + return $this->error('文件ID与路径不能为空'); + } + + // 删除 OSS 上的文件 + $ossService = new OssService(); + $ossService->deleteFile($path); + + $result = $uploadedFilesService->deleteFile($fileId); + + if ($result) { + return $this->success([], '文件删除成功'); + } else { + return $this->error('文件删除失败'); + } + } catch (\Throwable $e) { + return $this->error('文件删除失败: ' . $e->getMessage()); + } + } + + /** + * 获取分区配置 + * + * @param Request $request + * @return \support\Response + */ + public function getPartitionConfig(Request $request) + { + try { + $uploadedFilesService = new UploadedFilesService(); + $config = $uploadedFilesService->getPartitionConfig(); + + return $this->success($config); + } catch (\Throwable $e) { + return $this->error('获取分区配置失败: ' . $e->getMessage()); + } + } + + + + public function proxy(Request $request) + { + $filePath = $request->get('file'); // 原OSS文件路径 + try { + // 生成签名URL + $signedUrl = (new \app\common\service\OssService())->generatePrivateUrl($filePath); + + // 获取图片数据并返回 + $imageData = file_get_contents($signedUrl); + + // 根据文件类型设置Content-Type + $mime = mime_content_type('data://application/octet-stream;base64,'.base64_encode($imageData)); + + return response($imageData, 200, [ + 'Content-Type' => $mime, + 'Cache-Control' => 'public, max-age=600' // 客户端缓存10分钟 + ]); + } catch (\Exception $e) { + return response('Image not found '.$e->getMessage(), 404); + } + } +} \ No newline at end of file diff --git a/app/common/controller/UsedcarCrawlResultsController.php b/app/common/controller/UsedcarCrawlResultsController.php new file mode 100644 index 0000000..bdeac97 --- /dev/null +++ b/app/common/controller/UsedcarCrawlResultsController.php @@ -0,0 +1,33 @@ +service = new UsedcarCrawlResultsService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/UsedcarCrawlResultsImagesController.php b/app/common/controller/UsedcarCrawlResultsImagesController.php new file mode 100644 index 0000000..5560fda --- /dev/null +++ b/app/common/controller/UsedcarCrawlResultsImagesController.php @@ -0,0 +1,33 @@ +service = new UsedcarCrawlResultsImagesService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/UserController.php b/app/common/controller/UserController.php new file mode 100644 index 0000000..f49fba0 --- /dev/null +++ b/app/common/controller/UserController.php @@ -0,0 +1,128 @@ +service = new UserService(); + } + + /** + * 获取组员列表 + * + * @param Request $request 请求对象 + * @return mixed 返回分页数据及总数 + */ + public function team(Request $request) + { + try { + $raw = $this->service->serviceTeam($request); + $data = $this->transformData($raw, method_exists($this, 'indexFormat') + ? [$this, 'indexFormat'] + : null); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 标记组员离职 + * + * @param Request $request 请求对象 + * @return mixed 返回分页数据及总数 + */ + public function resignMember(Request $request) + { + try { + $userId = $request->get('user_id'); + if(empty($userId)){ + return $this->error('组员id不能为空'); + } + $user = $this->service->resignMember($request); + return $this->success($user, '获取用户信息'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 用户信息(登录后调用) + */ + public function info(Request $request) + { + try { + $uid = $request->user->uid; + $systemCode = $request->system_code; + $result = (new UserService())->getUserInfo($uid, $systemCode); + return $this->success($result, '获取用户信息'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 用户信息 + */ + public function getUser(Request $request) + { + try { + $uid = $request->user->uid; + $corp_abbr = $request->basic_data['corp_abbr']; + $result = (new UserService())->getUser($uid,$corp_abbr); + return $this->success($result, '获取用户信息'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 编辑用户 + */ + public function edit(Request $request) + { + try { + $uid = $request->user->uid; + $data = $request->all(); + $result = (new UserService())->edit($uid,$data); + unset($result['password']); + unset($result['union_id']); + unset($result['open_id']); + unset($result['tenant_key']); + return $this->success($result, '编辑成功'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 修改密码 + * @param Request $request + * @return \support\Response + * @throws \Random\RandomException + */ + public function savePassword(Request $request) + { + try { + $uid = $request->user->uid; + $data = $request->all(); + $result = (new UserService())->savePassword($uid,$data);; + return $this->success($result, '编辑成功'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + +} \ No newline at end of file diff --git a/app/common/controller/VehColorController.php b/app/common/controller/VehColorController.php new file mode 100644 index 0000000..4473c44 --- /dev/null +++ b/app/common/controller/VehColorController.php @@ -0,0 +1,56 @@ +service = new VehColorService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + if (isset($data['created_by']) && $data['created_by']) + $data['created_by_name'] = User::where('id', $data['created_by'])->value('name'); + if (isset($data['updated_by']) && $data['updated_by']) + $data['updated_by_name'] = User::where('id', $data['updated_by'])->value('name'); + return $data; + } + + /** + * 数据格式化 + * + * @param array $data + * @return array + */ + protected function showFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + if (isset($data['created_by']) && $data['created_by']) + $data['created_by_name'] = User::where('id', $data['created_by'])->value('name'); + if (isset($data['updated_by']) && $data['updated_by']) + $data['updated_by_name'] = User::where('id', $data['updated_by'])->value('name'); + return $data; + } +} \ No newline at end of file diff --git a/app/common/controller/VehDictI18nController.php b/app/common/controller/VehDictI18nController.php new file mode 100644 index 0000000..6a87c9f --- /dev/null +++ b/app/common/controller/VehDictI18nController.php @@ -0,0 +1,33 @@ +service = new VehDictI18nService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/VehDictMappingController.php b/app/common/controller/VehDictMappingController.php new file mode 100644 index 0000000..d1a1dfe --- /dev/null +++ b/app/common/controller/VehDictMappingController.php @@ -0,0 +1,33 @@ +service = new VehDictMappingService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/VehModificationController.php b/app/common/controller/VehModificationController.php new file mode 100644 index 0000000..314ca40 --- /dev/null +++ b/app/common/controller/VehModificationController.php @@ -0,0 +1,47 @@ +service = new VehModificationService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + return $data; + } + + /** + * 数据格式化 + * + * @param array $data + * @return array + */ + protected function showFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + return $data; + } +} \ No newline at end of file diff --git a/app/common/controller/VehMoneyValueController.php b/app/common/controller/VehMoneyValueController.php new file mode 100644 index 0000000..b96d3f9 --- /dev/null +++ b/app/common/controller/VehMoneyValueController.php @@ -0,0 +1,33 @@ +service = new VehMoneyValueService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/VehVehicleListingController.php b/app/common/controller/VehVehicleListingController.php new file mode 100644 index 0000000..59d49aa --- /dev/null +++ b/app/common/controller/VehVehicleListingController.php @@ -0,0 +1,33 @@ +service = new VehVehicleListingService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/VehVehicleSkuController.php b/app/common/controller/VehVehicleSkuController.php new file mode 100644 index 0000000..9c41686 --- /dev/null +++ b/app/common/controller/VehVehicleSkuController.php @@ -0,0 +1,223 @@ +service = new VehVehicleSkuService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + return $data; + } + + protected function showFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + return $data; + } + + + public function store(Request $request) + { + return $this->error('请优先抓取车辆信息,如抓取不到相应车型请联系管理员,暂停手动录入车辆信息'); + try { + $data = $this->validate($this->service->getValidator(), $request->post(), 'store'); + $res = $this->service->serviceStore($request, $data); + return $this->success($res, '创建成功'); + } catch (\PDOException $e) { + $errorCode = $e->getCode(); + $errorMessage = $e->getMessage(); + if ($errorCode == '23000') { + if (strpos($errorMessage, "Duplicate entry") !== false && strpos($errorMessage, "uk_fingerprint") !== false) { + return $this->error("该车辆指纹已存在,请勿重复提交"); + } + // 其他类型的完整性约束错误 + throw new BusinessException("数据完整性错误", 0, $e); + } + // 重新抛出其他类型的异常 + throw $e; + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function update(Request $request) + { + try { + $data = $this->validate($this->service->getValidator(), $request->post(), 'update'); + $res = $this->service->serviceUpdate($request, $data); + return $this->success([], '更新成功'); + } catch (\PDOException $e) { + $errorCode = $e->getCode(); + $errorMessage = $e->getMessage(); + if ($errorCode == '23000') { + if (strpos($errorMessage, "Duplicate entry") !== false && strpos($errorMessage, "uk_fingerprint") !== false) { + return $this->error("该车辆指纹已存在,请勿重复提交"); + } + // 其他类型的完整性约束错误 + throw new BusinessException("数据完整性错误", 0, $e); + } + // 重新抛出其他类型的异常 + throw $e; + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function preset(Request $request){ + + + try { + $series_id = $request->input('series_id'); + $series_code = BrandSeries::where('id', $series_id)->value('series_code'); + $data = [ + 'series_code' => $series_code, + ]; + $result = $this->service->getList($data); + return $this->success($result, '获取车型列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + + public function fields_desc(Request $request) + { + $data = [ + 'sku_code' , // 'SKU编码,系统生成', + 'fingerprint' , // '规格指纹(sha256)去重', + 'brand_code' , // '品牌代码,如 BYD、HYUNDAI、KIA', + 'brand_name' , // '品牌名称展示,如 比亚迪、现代、起亚', + 'series_code' , // '车系代码,如 ELANTRA、SONGPLUS', + 'series_name' , // '车系名称展示', + 'model_code' , // '车型/配置代码(工厂/内部编码)', + 'model_name' , // '车型/配置名称(可读,如 伊兰特 1.5L、海狮07)', + 'model_year' , // '年款(YYYY)', + 'trim_code' , // '版型代码(TRIM)', + 'trim_level_code' , // '配置等级代码(TRIM_LEVEL)', + 'edition_code' , // '特别版代码(EDITION)', + 'trim' , // '版型/细分配置(如 TOP旗舰版、荣耀版)', + 'trim_level' , // '配置等级(豪华型/旗舰型等,便于筛选)', + 'edition_name' , // '特别版/纪念版(如 荣耀版、致敬版等)', + 'body_type_code' , // '车身类型:SEDAN/SUV/HB/MPV/Pickup', + 'drive_type_code' , // '驱动:FWD/RWD/AWD/4WD', + 'motor_layout_code' , // '电驱/电机布置(MOTOR_LAYOUT):FWD/RWD/AWD/DM-i/DM-p 等', + 'fuel_type_code' , // '能源:GAS/HEV/PHEV/EV/EREV', + 'engine_disp_l' , // '排量(L),如 1.5;纯电为空', + 'engine_turbo' , // '0' COMMENT '是否涡轮(0/1)', + 'engine_power_kw' , // '最大功率(kW)', + 'battery_kwh' , // '电池容量(kWh)', + 'transmission_code' , // '变速箱:MT/AT/CVT/DCT/1SPEED', + 'gears' , // '挡位数(EV多为1)', + 'range_km' , // '续航里程(km)', + 'range_std_code' , // '续航标准:CLTC/WLTP/EPA/NEDC', + 'doors' , // '门数', + 'seats' , // '座位数', + 'weight_kg' , // '整备质量(kg)', + 'dimensions_mm' , // '尺寸(mm):{length,width,height,wheelbase}', + 'color_exterior_code' , // '外观色代码', + 'color_exterior_text' , // '外观色名称', + 'color_interior_code' , // '内饰色代码', + 'color_interior_text' , // '内饰色名称', + 'options_official' , // '官方选装(结构化 JSON)', + 'features' , // '功能/安全/智能配置(结构化 JSON)', + 'emission_std_code' , // '排放标准:CHN6/EURO6/EPA', + 'origin_country' , // '原产国 ISO-3166-1 alpha-2', + 'currency' , // 'CNY' COMMENT '币种 ISO-4217', + 'msrp' , // '建议零售价/参考价', + 'is_active' , // '1' COMMENT '启用(1)/禁用(0)', + 'corp_id' , // '0' COMMENT '租户/公司(全局库可写0)', + 'remark' , // '备注', + 'created_by' , // '0', + 'updated_by' , // '0', + ]; + + + $data = [ + 'fingerprint' , // '规格指纹(sha256)去重', + 'brand_name' , // '品牌名称展示,如 比亚迪、现代、起亚', + 'series_name' , // '车系名称展示', + 'model_name' , // '车型/配置名称(可读,如 伊兰特 1.5L、海狮07)', + 'model_year' , // '年款(YYYY)', + 'trim' , // '版型/细分配置(如 TOP旗舰版、荣耀版)', + 'trim_level' , // '配置等级(豪华型/旗舰型等,便于筛选)', + 'edition_name' , // '特别版/纪念版(如 荣耀版、致敬版等)', + 'engine_disp_l' , // '排量(L),如 1.5;纯电为空', + 'engine_turbo' , // '0' COMMENT '是否涡轮(0/1)', + 'engine_power_kw' , // '最大功率(kW)', + 'battery_kwh' , // '电池容量(kWh)', + 'gears' , // '挡位数(EV多为1)', + 'range_km' , // '续航里程(km)', + 'doors' , // '门数', + 'seats' , // '座位数', + 'weight_kg' , // '整备质量(kg)', + 'dimensions_mm' , // '尺寸(mm):{length,width,height,wheelbase}', + 'color_exterior_text' , // '外观色名称', + 'color_interior_text' , // '内饰色名称', + 'options_official' , // '官方选装(结构化 JSON)', + 'features' , // '功能/安全/智能配置(结构化 JSON)', + 'origin_country' , // '原产国 ISO-3166-1 alpha-2', + 'currency' , // 'CNY' COMMENT '币种 ISO-4217', + 'msrp' , // '建议零售价/参考价', + 'is_active' , // '1' COMMENT '启用(1)/禁用(0)', + 'corp_id' , // '0' COMMENT '租户/公司(全局库可写0)', + 'remark' , // '备注', + 'created_by' , // '0', + 'updated_by' , // '0', + + 'body_type', // '车身类型' + 'drive_type', // '驱动:FWD/RWD/AWD/4WD', + 'motor_layout', // '电机布置(MOTOR_LAYOUT):FWD/RWD/AWD/DM-i/DM-p 等' + 'fuel_type', // '能源:GAS/HEV/PHEV/EV/EREV' + 'transmission', // '变速箱:MT/AT/CVT/DCT/1SPEED' + 'range_std', // '续航标准:CLTC/WLTP/EPA/NEDC' + 'emission_std', // '排放标准:CHN6/EURO6/EPA' + + 'sku_code' , // 'SKU编码,系统生成', + 'brand_code' , // '品牌代码,如 BYD、HYUNDAI、KIA', + 'series_code' , // '车系代码,如 ELANTRA、SONGPLUS', + 'model_code' , // '车型/配置代码(工厂/内部编码)', + 'trim_code' , // '版型代码(TRIM)', + 'trim_level_code' , // '配置等级代码(TRIM_LEVEL)', + 'edition_code' , // '特别版代码(EDITION)', + 'color_exterior_code' , // '外观色代码', + 'color_interior_code' , // '内饰色代码', + + 'body_type_code' , // '车身类型:SEDAN/SUV/HB/MPV/Pickup', + 'drive_type_code' , // '驱动:FWD/RWD/AWD/4WD', + 'motor_layout_code' , // '电驱/电机布置(MOTOR_LAYOUT):FWD/RWD/AWD/DM-i/DM-p 等', + 'fuel_type_code' , // '能源:GAS/HEV/PHEV/EV/EREV', + 'transmission_code' , // '变速箱:MT/AT/CVT/DCT/1SPEED', + 'range_std_code' , // '续航标准:CLTC/WLTP/EPA/NEDC', + 'emission_std_code' , // '排放标准:CHN6/EURO6/EPA', + ]; + } +} \ No newline at end of file diff --git a/app/common/controller/VehVehicleUnitController.php b/app/common/controller/VehVehicleUnitController.php new file mode 100644 index 0000000..3d2e7e2 --- /dev/null +++ b/app/common/controller/VehVehicleUnitController.php @@ -0,0 +1,33 @@ +service = new VehVehicleUnitService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/logic/ApprovalFlowsLogic.php b/app/common/logic/ApprovalFlowsLogic.php new file mode 100644 index 0000000..2157d28 --- /dev/null +++ b/app/common/logic/ApprovalFlowsLogic.php @@ -0,0 +1,17 @@ +validateUser($data['account'], $data['password']); + $system = $this->validateSystem($data['system_code']); + $this->validateSystemPermission($user, $system); + + return $this->generateTokensAndReturn($user, $system, $data); + } + + /** + * 飞书登录 + * 如果用户不存在则新增用户信息 + */ + public function feishuLogin(array $data): array + { + $feishuUserInfo = $this->getFeishuUserInfo($data); + var_dump($feishuUserInfo); + return $feishuUserInfo; + $user = $this->getOrCreateUser($feishuUserInfo); + + $system = $this->validateSystem($data['system_code']); + if ($user->isLogin) { + $this->validateSystemPermission($user, $system); + } + + return $this->generateTokensAndReturn($user, $system, $data); + } + + /** + * 注册 + */ + public function register(array $data) + { + try { + // 获取可用用户编号 + $sn = $this->getAvailableUserSn(); + $data['sn'] = $sn->user_sn; + + // 对密码进行哈希处理 + $data['password'] = password_hash($data['password'], PASSWORD_DEFAULT); + + // 获取企业ID + $corpInfo = $this->validateCorp($data['corp_abbr']); + $data['corp_id'] = $corpInfo->id; + + // 处理账户 + $data = $data + normalizeAccount($data['account']); + + // 创建用户记录 + $user = $this->createUser($data); + + // 更新用户编号的用户 ID + $this->updateUserId($user->id, $sn->user_sn); + + $system = $this->validateSystem($data['system_code']); + return $this->generateTokensAndReturn($user, $system, $data); + } catch (\Throwable $e) { + throw $e; + } + } + + /** + * 查找或创建飞书用户 + */ + private function getOrCreateUser(array $feishuUserInfo) + { + try { + $phone = $this->sanitizePhone($feishuUserInfo['mobile'] ?? ''); + $email = $feishuUserInfo['enterprise_email']; + // $user = User::where('corp_id', $feishuUserInfo['corp_id']) + // ->where(function($query) use ($phone, $email) { + // $query->where('phone', $phone) + // ->orWhere('email', $email); + // }) + // ->first(); + $userByPhone = User::where('corp_id', $feishuUserInfo['corp_id']) + ->where('phone', $phone) + ->where('phone', '!=', '') + ->first(); + $userByEmail = User::where('corp_id', $feishuUserInfo['corp_id']) + ->where('email', $email) + ->where('email', '!=', '') + ->first(); + $user = $userByPhone ?: $userByEmail; + if (!$user) { + // 获取编号 + $userSn = $this->getAvailableUserSn(); + // 创建用户 + $user = User::create([ + 'name' => $feishuUserInfo['name'], + 'avatar' => $feishuUserInfo['avatar_middle'], + 'phone' => $this->sanitizePhone($feishuUserInfo['mobile'] ?? ''), + 'email' => $feishuUserInfo['enterprise_email'], + 'union_id' => $feishuUserInfo['union_id'], + 'user_id' => $feishuUserInfo['user_id'], + 'open_id' => $feishuUserInfo['open_id'], + 'tenant_key' => $feishuUserInfo['tenant_key'], + 'corp_id' => $feishuUserInfo['corp_id'], + 'password' => password_hash($userSn->user_sn, PASSWORD_DEFAULT) + ]); + // 更新使用 + $this->updateUserId($user->id, $userSn->user_sn); + // 更新编号 + if (!$user->update(['sn'=>$userSn->user_sn])) { + throw new BusinessException('编号绑定失败'); + } + // 是否登陆 + $user->isLogin = false; + + $options = [ + 'corp_id' => $user->corp_id, + 'user_id' => $user->id + ]; + \Webman\RedisQueue\Redis::send('perm-agt', $options); + } else { + $updated = [ + 'union_id' => $feishuUserInfo['union_id'], + 'user_id' => $feishuUserInfo['user_id'], + 'open_id' => $feishuUserInfo['open_id'], + 'tenant_key' => $feishuUserInfo['tenant_key'] + ]; + if ($phone && $phone == $user->phone) { + $updated['phone'] = $phone; + } + if ($phone && $phone == $user->phone) { + $updated['email'] = $email; + } + // 更新飞书信息 + $user->update($updated); + // 是否登陆 + $user->isLogin = true; + } + return $user; + } catch (\Throwable $e) { + throw $e; + } + } + + /** + * 过滤和格式化手机号 + */ + private function sanitizePhone($mobile) + { + return isset($mobile) ? str_replace('+86', '', $mobile) : ''; + } + + /** + * 生成并记录token,返回包含token的响应数据 + */ + private function generateTokensAndReturn(User $user, Systems $system, array $data): array + { + $payload = $this->buildJwtPayload($user, $system); + $token = $this->generateAccessToken($payload); + $refreshToken = $this->generateRefreshToken($user->id, $data['system_code']); + $homeUrl = $this->getHome($system->id, $payload); + + // 记录 token 到 Redis 和 MySQL + $this->recordUserToken($user->id, $token, $data['system_code']); + + return [ + 'access_token' => $token, + 'refresh_token' => $refreshToken, + 'expires_in' => config('jwt.jwt_expiration'), + 'system_code' => $data['system_code'], + 'corp_abbr' => $data['corp_abbr'], + 'login_type' => $data['login_type'], + 'home_url' => $homeUrl, + // 'payload' => $payload, + ]; + } + + /** + * 获取飞书用户信息 + */ + private function getFeishuUserInfo(array $data) + { + $app = [ + 'cli_a8033d607cba500d' => 'OZW2UOeJ0g4HJfXKSn6skcymCOyYIdDR', + ]; + if(empty($app[$data['app_id']])){ + throw new BusinessException('app_id非法'); + } + $feishu = new Feishu($data['app_id'], $app[$data['app_id']]); + + //回调地址 + $redirect_uri = $data['r_url']; + + $userAccessToken = $feishu->userAccessToken($data['feishu_code'], $redirect_uri); + $userInfo = $feishu->userInfo($userAccessToken['access_token']); + + if ($userInfo['code'] != 0) { + throw new BusinessException('授权失败,请重试'); + } + + return $userInfo['data'] + $data ; + } + + /** + * 更新用户编号 + */ + private function updateUserId($userId, $userSn) + { + $updated = UserSn::where('user_sn', $userSn) + ->update(['user_id' => $userId]); + + if ($updated === 0) { + throw new BusinessException('用户编号不存在或更新失败'); + } + + return $updated; + } + + /** + * 记录用户token到 Redis 和 MySQL + */ + protected function recordUserToken(int $userId, string $token, string $systemCode) + { + $request = request(); + $ip = $request ? $request->getRealIp() : ''; + $ua = $request ? $request->header('user-agent', '') : ''; + $now = time(); + $expireAt = $now + config('jwt.jwt_expiration'); + + // Redis Set 用户token集合 + Redis::sAdd("user_tokens:{$userId}:{$systemCode}", $token); + Redis::expire("user_tokens:{$userId}:{$systemCode}", config('jwt.jwt_expiration')); + + // Redis Hash token元信息 + $meta = [ + 'system_code' => $systemCode, + 'login_ip' => $ip, + 'user_agent' => $ua, + 'login_time' => $now, + 'expired_at' => $expireAt, + ]; + Redis::hMSet("user_token_meta:{$token}", $meta); + Redis::expire("user_token_meta:{$token}", config('jwt.jwt_expiration')); + + // 可选:MySQL 持久化 + try { + UserTokenLog::create([ + 'user_id' => $userId, + 'token' => $token, + 'system_code'=> $systemCode, + 'login_ip' => $ip, + 'user_agent' => $ua, + 'login_time' => $now, + 'expired_at' => $expireAt, + 'is_valid' => 1, + ]); + } catch (\Throwable $e) { + Log::error('Token log write failed: '.$e->getMessage()); + } + } + + /** + * 校验token有效性 + */ + public function verifyToken(string $token, string $systemCode): array + { + if (!$systemCode) throw new BusinessException('缺少system_code', 422); + + $payload = (new Jwt())->verifyToken($token); + + // 校验黑名单 + if (Redis::has("token_blacklist:{$token}")) { + throw new BusinessException('token已失效', 403); + } + + // 强制校验aud(系统隔离) + if ($systemCode && $payload['aud'] != $systemCode && $payload['aud'] != ($payload['system_code'] ?? '')) { + throw new BusinessException('token不属于当前系统', 403); + } + + return (array)$payload; + } + + /** + * 获取飞书应用信息 + */ + public function feishuAppInfo(string $app_id, string $systemCode) + { + $fsAppInfo = CorpFeishuApp::where('app_id', $app_id)->first(); + var_dump($fsAppInfo); + return [ + 'app_id' => $fsAppInfo->app_id, + 'app_secret' => $fsAppInfo->app_secret, + 'r_url' => $fsAppInfo->r_url, + 'scope' => $fsAppInfo->scopes, + 'state' => $fsAppInfo->state, + ]; + } + + /** + * 验证用户信息 + */ + private function validateUser(string $account, string $password) + { + $user = User::byAccount($account)->first(); + if (!$user || !password_verify($password, $user->password)) { + throw new BusinessException('账号或密码错误', 401); + } + if ($user->status != 1) { + throw new BusinessException('账号已禁用', 403); + } + return $user; + } + + /** + * 验证系统信息 + */ + private function validateSystem(string $systemCode) + { + $system = Systems::where('code', $systemCode)->first(); + if (!$system) { + throw new BusinessException('系统不存在', 403); + } + return $system; + } + + /** + * 验证公司信息 + */ + private function validateCorp(string $corpAbbr) + { + $corp = Corp::where('abbr', $corpAbbr)->first(); + if (!$corp) { + throw new BusinessException('公司不存在', 403); + } + return $corp; + } + + /** + * 验证飞书应用信息 + */ + private function validateFeishuApp(int $corpId, int $systemId) + { + $feishuAppInfo = CorpFeishuApp::where('corp_id', $corpId)->where('system_id', $systemId)->first(); + if (!$feishuAppInfo) { + throw new BusinessException('飞书应用不存在', 403); + } + return $feishuAppInfo; + } + + /** + * 验证用户系统权限 + */ + private function validateSystemPermission(User $user, Systems $system) + { + if ($system->is_role) { + $systemIds = $user->systems->pluck('id')->toArray(); + if (!in_array($system->id, $systemIds)) { + throw new BusinessException('无该系统权限', 403); + } + } + } + + /** + * 获取可用的用户编号 + * + * @return \app\common\model\UserSn 用户编号模型实例 + * @throws BusinessException 当未找到适用编号时抛出 + */ + private function getAvailableUserSn(): \app\common\model\UserSn + { + $userSnModel = new UserSn(); + $sn = $userSnModel->where('user_id', '=', 0)->inRandomOrder()->first(); + if (!$sn) { + Log::error('用户注册时未找到适用编号'); + throw new BusinessException('未找到适用编号'); + } + return $sn; + } + + /** + * 创建用户记录 + * + * @param array $data 包含用户信息的数组 + * @return \app\common\model\User 用户模型实例 + */ + private function createUser(array $data): \app\common\model\User + { + try { + return User::create($data); + } catch (\Throwable $e) { + Log::error('用户注册时创建用户记录失败', [ + 'data' => $data, + 'error' => $e->getMessage() + ]); + throw new BusinessException('用户注册失败,请稍后重试', 500); + } + } + + /** + * JWT payload 构建 + * 可扩展 payload 字段 + */ + private function buildJwtPayload(User $user, ?Systems $system = null): array + { + $now = time(); + $exp = $now + config('jwt.jwt_expiration'); + $system_ids = $user->systems ? $user->systems->pluck('id')->toArray() : []; + $role_ids = $user->roles ? $user->roles->pluck('id')->toArray() : []; + + return [ + 'sub' => 'user_' . $user->id, + 'aud' => $system->code, + 'iat' => $now, + 'nbf' => $now, + 'exp' => $exp, + 'typ' => 'access', + 'uid' => $user->id, + 'uname' => $user->name, + 'role_ids' => $role_ids, + 'system_ids' => $system_ids + ]; + } + + /** + * JWT签发 + */ + private function generateAccessToken(array $payload) + { + return (new Jwt())->generateToken($payload); + } + + /** + * 刷新令牌生成 + */ + private function generateRefreshToken(int $userId, string $systemCode) + { + $refreshToken = bin2hex(random_bytes(32)); + // user_id+system_code只允许一个refresh_token生效 + Redis::set("refresh_token:{$userId}:{$systemCode}", $refreshToken, config('jwt.jwt_refresh_token_expiration')); + // 建立refresh_token到user_id+system_code映射,便于校验 + Redis::set("refresh_token_map:{$refreshToken}:{$systemCode}", $userId, config('jwt.jwt_refresh_token_expiration')); + return $refreshToken; + } + + + /** + * 刷新token + * @param string $refreshToken + * @param string|null $systemCode 可选,指定系统 + * @return array + */ + public function refreshToken(string $refreshToken, ?string $systemCode = null): array + { + if (!$systemCode) throw new BusinessException('缺少system_code', 422); + + // 校验refresh_token + $userId = Redis::get("refresh_token_map:{$refreshToken}:{$systemCode}"); + if (!$userId) { + throw new BusinessException('refresh_token无效或已过期', 401); + } + + $validToken = Redis::get("refresh_token:{$userId}:{$systemCode}"); + if ($validToken !== $refreshToken) { + throw new BusinessException('refresh_token无效或已过期', 401); + } + + $user = User::find($userId); + if (!$user || $user->status != 1) { + throw new BusinessException('用户无效', 403); + } + + $system = $this->validateSystem($systemCode); + $this->validateSystemPermission($user, $system); + + $payload = $this->buildJwtPayload($user, $system); + $token = $this->generateAccessToken($payload); + $refreshToken = $this->generateRefreshToken($user->id, $systemCode); + $this->recordUserToken($user->id, $token, $systemCode); + + return [ + 'access_token' => $token, + 'refresh_token' => $refreshToken, + 'expires_in' => config('jwt.jwt_expiration'), + // 'payload' => $payload, + ]; + } + + /** + * 登出/失效token + */ + public function logout(?string $token, ?string $systemCode = null) + { + if ($token) { + Redis::set("token_blacklist:{$token}", 1, config('jwt.jwt_expiration')); + $payload = null; + try { $payload = (new Jwt())->verifyToken($token); } catch (\Throwable $e) {} + $userId = $payload['uid'] ?? null; + $aud = $payload['aud'] ?? null; + $sc = $systemCode ?? $aud; + if ($userId && $sc) { + Redis::sRem("user_tokens:{$userId}:{$sc}", $token); + } + Redis::del("user_token_meta:{$token}"); + UserTokenLog::where('token', $token)->update(['is_valid'=>0]); + } + } + + /** + * 强制拉黑token + */ + public function blockToken(string $token, ?string $systemCode = null) + { + if (!$token) { + throw new BusinessException('token不能为空', 422); + } + Redis::set("token_blacklist:{$token}", 1, config('jwt.jwt_expiration')); + $payload = null; + try { $payload = (new Jwt())->verifyToken($token); } catch (\Throwable $e) {} + $userId = $payload['uid'] ?? null; + $aud = $payload['aud'] ?? null; + $sc = $systemCode ?? $aud; + if ($userId && $sc) { + Redis::sRem("user_tokens:{$userId}:{$sc}", $token); + } + Redis::del("user_token_meta:{$token}"); + UserTokenLog::where('token', $token)->update(['is_valid'=>0]); + } + + + public function getHome($systemId, $data) + { + // 2025.10.30 临时处理 ====== start + if ($systemId == 1) { + if (in_array(1, $data['role_ids'])) { + return '/dashboard/console'; + } + if (count($data['role_ids']) == 1 && in_array(5, $data['role_ids'])) { + return '/dashboard/console/backoffice'; + } + if (count($data['role_ids']) == 1 && in_array(6, $data['role_ids'])) { + return '/dashboard/console/finance'; + } + } + // 2025.10.30 临时处理 ====== end + + $info = Resource::where('system_id', $systemId) + ->where('is_home', 1) + ->first(); + + if (!$info) { + return '/dashboard/console'; + } + + // 递归拼接 URL + $url = $this->buildParentUrl($info); + + return $url ?: '/dashboard/console'; + } + + /** + * 递归构建父级 URL + * @param Resource $resource + * @return string + */ + protected function buildParentUrl(Resource $resource) + { + $url = $resource->url ?? ''; + + // 如果存在父级,则递归拼接 + if ($resource->parent_id != 0) { + $parent = Resource::find($resource->parent_id); + if ($parent) { + $parentUrl = $this->buildParentUrl($parent); + $url = $parentUrl . ($url ? '/' . ltrim($url, '/') : ''); + } + } + + return $url; + } +} \ No newline at end of file diff --git a/app/common/logic/BrandLogic.php b/app/common/logic/BrandLogic.php new file mode 100644 index 0000000..ef46a5a --- /dev/null +++ b/app/common/logic/BrandLogic.php @@ -0,0 +1,22 @@ +orderBy('hot','desc')->orderBy('sort','desc')->get(['id', 'brand_code', 'brand_name', 'logo', 'description']); + } + +} \ No newline at end of file diff --git a/app/common/logic/BrandSeriesLogic.php b/app/common/logic/BrandSeriesLogic.php new file mode 100644 index 0000000..956ca55 --- /dev/null +++ b/app/common/logic/BrandSeriesLogic.php @@ -0,0 +1,22 @@ +where($where)->orderBy('sort','desc')->get(['id', 'series_code', 'series_name', 'description']); + } + +} \ No newline at end of file diff --git a/app/common/logic/CorpFeishuAppLogic.php b/app/common/logic/CorpFeishuAppLogic.php new file mode 100644 index 0000000..2613c6a --- /dev/null +++ b/app/common/logic/CorpFeishuAppLogic.php @@ -0,0 +1,23 @@ +where('system_id', $systemId)->first(['id', 'corp_id', 'name', 'app_id', 'r_url', 'state']); + } + +} \ No newline at end of file diff --git a/app/common/logic/CorpLogic.php b/app/common/logic/CorpLogic.php new file mode 100644 index 0000000..60e46e6 --- /dev/null +++ b/app/common/logic/CorpLogic.php @@ -0,0 +1,21 @@ +orderBy('sort', 'asc')->get(['id', 'name', 'abbr', 'tenant_key']); + } +} \ No newline at end of file diff --git a/app/common/logic/CountryLogic.php b/app/common/logic/CountryLogic.php new file mode 100644 index 0000000..0a753ee --- /dev/null +++ b/app/common/logic/CountryLogic.php @@ -0,0 +1,17 @@ +update($data); + } +} \ No newline at end of file diff --git a/app/common/logic/FinanceReconDubaiFilesLogic.php b/app/common/logic/FinanceReconDubaiFilesLogic.php new file mode 100644 index 0000000..360f23f --- /dev/null +++ b/app/common/logic/FinanceReconDubaiFilesLogic.php @@ -0,0 +1,17 @@ +['msrp']]); // 可选排除 + */ +class Fingerprint +{ + /** 你的 veh_vehicle_sku 所有列(保持与表结构一致,顺序无关但建议完整) */ + public const COLS = [ + 'id','sku_code','fingerprint', + 'brand_code','brand_name','series_code','series_name', + 'model_code','model_name','model_year', + 'trim_code','trim_level_code','edition_code', + 'trim','trim_level','edition_name', + 'body_type_code','drive_type_code','motor_layout_code','fuel_type_code', + 'engine_disp_l','engine_turbo','engine_power_kw','battery_kwh', + 'transmission_code','gears','range_km','range_std_code', + 'doors','seats','weight_kg','dimensions_mm', + 'color_exterior_code','color_exterior_text', + 'color_interior_code','color_interior_text', + 'options_official','features','emission_std_code', + 'origin_country','currency','msrp', + 'is_active','corp_id','remark', + 'created_by','updated_by','created_at','updated_at', + ]; + + /** 默认剔除:技术/循环依赖字段 */ + public const EXCLUDE_DEFAULT = [ + 'id','sku_code','fingerprint', 'dimensions_mm', 'options_official', 'features', + 'origin_country','currency','msrp', + 'is_active','corp_id','remark', + 'created_by','updated_by','created_at','updated_at' + ]; + + /** 识别为“code类”的字段模式(统一大写) */ + private const CODE_FIELD_REGEX = '/(_code|_country|^currency)$/i'; + + /** 识别为 JSON 字段 */ + private const JSON_FIELDS = ['dimensions_mm','options_official','features']; + + /** + * 从 Eloquent 模型生成 + */ + public static function makeFromModel($model, array $opts = []): string + { + return self::makeFromArray($model->toArray(), $opts); + } + + /** + * 从数组生成指纹 + * @param array $row 一般为 $_POST 合并后的业务数据或 $model->toArray() + * @param array $opts ['exclude'=>[], 'extra'=>[], 'salt'=>string] + * - exclude: 额外排除的字段(默认仅排除 id,fingerprint) + * - extra : 额外拼入的键值(例如上下文、租户策略),会参与计算 + * - salt : 额外加盐字符串 + */ + public static function makeFromArray(array $row, array $opts = []): string + { + $exclude = array_values(array_unique(array_merge(self::EXCLUDE_DEFAULT, $opts['exclude'] ?? []))); + $extra = $opts['extra'] ?? []; + $salt = $opts['salt'] ?? ''; + + // 1) 统一抽取“表中定义的字段” + extra,缺失字段也要纳入(值按 NULL 处理) + $data = []; + foreach (self::COLS as $col) { + if (in_array($col, $exclude, true)) continue; + $data[$col] = array_key_exists($col, $row) ? $row[$col] : null; + } + // extra 也参与 + foreach ($extra as $k => $v) { + if (!is_string($k) || $k === '') continue; + $data['__extra__'.$k] = $v; + } + + // 2) 规范化(按字段特征) + $canon = []; + foreach ($data as $k => $v) { + $canon[$k] = self::normalize($k, $v); + } + + // 3) 键名排序,确保顺序无关 + ksort($canon, SORT_STRING); + + // 4) 拼成稳定串: key=value | key=value... + $parts = []; + foreach ($canon as $k => $v) { + // 为空也必须占位,避免 key 消失影响其他键的意义 + $parts[] = $k.'='.$v; + } + if ($salt !== '') { + $parts[] = '__salt__='.$salt; + } + $plain = implode('|', $parts); + + // 5) sha256 + return hash('sha256', $plain); + } + + /** + * 规范化单个字段 + * - NULL -> 特殊占位 "∅"(避免与空串混淆) + * - 布尔/整数/浮点 -> 统一数字字符串 + * - code类字段 -> 去首尾空白 & 转大写 + * - 普通字符串 -> trim & 压缩连续空白为单空格 + * - JSON字段 -> 解析后做“键排序 + 值规范化 + 索引数组可排序”,再无空白编码 + */ + private static function normalize(string $field, $value): string + { + if ($value === null) return '∅'; + + // 数值类型 + if (is_int($value)) return (string)$value; + if (is_bool($value)) return $value ? '1' : '0'; + + if (is_float($value) || (is_string($value) && is_numeric($value) && self::looksLikeNumberField($field))) { + // 统一浮点格式,最多 6 位小数并去尾零 + $num = (float)$value; + $s = number_format($num, 6, '.', ''); + $s = rtrim(rtrim($s, '0'), '.'); + return $s === '' ? '0' : $s; + } + + // JSON 字段 + if (in_array($field, self::JSON_FIELDS, true)) { + return self::normalizeJson($value); + } + + // 字符串 + if (is_string($value)) { + $s = trim($value); + // code类:统一大写 + if (preg_match(self::CODE_FIELD_REGEX, $field)) { + $s = mb_strtoupper($s, 'UTF-8'); + } else { + // 普通文本:压缩连续空白 + $s = preg_replace('/\s+/u', ' ', $s); + } + return $s === '' ? '' : $s; + } + + // 数组(极少出现,按 JSON 处理) + if (is_array($value)) { + return self::normalizeJson($value); + } + + // 其他类型(对象等):转 JSON 后处理 + return self::normalizeJson($value); + } + + /** 判断这个字段是否“看起来像数值字段” */ + private static function looksLikeNumberField(string $field): bool + { + static $numKeys = [ + 'engine_disp_l','engine_power_kw','battery_kwh','gears', + 'range_km','doors','seats','weight_kg','msrp','model_year', + 'created_at','updated_at','corp_id','is_active' + ]; + return in_array($field, $numKeys, true); + } + + /** + * JSON 规范化: + * - 字符串:若能 json_decode 则按 JSON;否则当普通字符串再规范化 + * - 对象:键名排序;值递归规范化 + * - 索引数组:元素递归规范化后,按字符串值排序(视为集合,避免顺序抖动) + * - 最终 json_encode(JSON_UNESCAPED_UNICODE|UNESCAPED_SLASHES) + */ + private static function normalizeJson($val): string + { + // 已是数组/对象 + if (is_array($val)) { + $normalized = self::canonArray($val); + return json_encode($normalized, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + } + + // 字符串尝试解析 + if (is_string($val)) { + $val = trim($val); + if ($val === '') return '""'; + $decoded = json_decode($val, true); + if (json_last_error() === JSON_ERROR_NONE) { + $normalized = self::canonArray($decoded); + return json_encode($normalized, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + } + // 非 JSON 字符串,按普通字符串规范化 + $s = preg_replace('/\s+/u', ' ', $val); + return json_encode($s, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + } + + // 其他类型:直接编码 + return json_encode($val, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + } + + /** 递归规范化数组/对象 */ + private static function canonArray($arr) + { + if (!is_array($arr)) return $arr; + + // 关联数组(对象) + if (self::isAssoc($arr)) { + $out = []; + ksort($arr, SORT_STRING); + foreach ($arr as $k => $v) { + $out[(string)$k] = self::canonValue($v); + } + return $out; + } + + // 索引数组(当集合处理:元素字符串化后排序) + $tmp = []; + foreach ($arr as $v) { + $tmp[] = self::canonValue($v); + } + // 将元素转为字符串再排序,确保稳定 + usort($tmp, function($a, $b) { + $sa = is_scalar($a) ? (string)$a : json_encode($a, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + $sb = is_scalar($b) ? (string)$b : json_encode($b, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES); + return strcmp($sa, $sb); + }); + return $tmp; + } + + private static function canonValue($v) + { + if (is_array($v)) return self::canonArray($v); + if (is_bool($v)) return $v ? 1 : 0; + if (is_int($v)) return $v; + if (is_float($v)) { + $s = number_format($v, 6, '.', ''); + $s = rtrim(rtrim($s, '0'), '.'); + return $s === '' ? '0' : $s; + } + if (is_string($v)) { + $s = trim($v); + $s = preg_replace('/\s+/u', ' ', $s); + return $s; + } + return $v; + } + + private static function isAssoc(array $arr): bool + { + // 有任意一个键不是 0..n 的递增整数,就视为关联数组 + $i = 0; + foreach ($arr as $k => $_) { + if ($k !== $i++) return true; + } + return false; + } +} \ No newline at end of file diff --git a/app/common/logic/InquiryTransmitConfigLogic.php b/app/common/logic/InquiryTransmitConfigLogic.php new file mode 100644 index 0000000..3c6782d --- /dev/null +++ b/app/common/logic/InquiryTransmitConfigLogic.php @@ -0,0 +1,17 @@ +orderBy('sort','desc')->get(); + } +} \ No newline at end of file diff --git a/app/common/logic/InvRequisitionApproveLogic.php b/app/common/logic/InvRequisitionApproveLogic.php new file mode 100644 index 0000000..4f1f795 --- /dev/null +++ b/app/common/logic/InvRequisitionApproveLogic.php @@ -0,0 +1,17 @@ +logicShow($config); + if ($msg) { + // 删除关联的用户状态记录 + $msg->userStatuses()->delete(); + // 删除消息本身 + $msg->delete(); + } + return true; + } +} \ No newline at end of file diff --git a/app/common/logic/MessageUserStatusLogic.php b/app/common/logic/MessageUserStatusLogic.php new file mode 100644 index 0000000..ea4b63e --- /dev/null +++ b/app/common/logic/MessageUserStatusLogic.php @@ -0,0 +1,17 @@ + 15L + if (!empty($s['engine_turbo']) && (int)$s['engine_turbo'] === 1) $key[] = 'T'; + if (!empty($s['transmission_code'])) $key[] = self::tok($s['transmission_code']); + break; + + case 'HEV': + case 'PHEV': + case 'EREV': + $key[] = $fuel; + if (!empty($s['engine_disp_l'])) $key[] = self::dispToken($s['engine_disp_l']); + if (!empty($s['motor_layout_code'])) $key[] = self::tok($s['motor_layout_code']); // 如 DM-I/DM-P/AWD + break; + + default: + $key[] = $fuel; + } + + if (!empty($s['drive_type_code'])) $key[] = self::tok($s['drive_type_code']); + + $parts[] = implode('_', array_filter($key)); + return strtoupper(implode('_', array_filter($parts))); + } + + /** 生成中文可读的 model_name(走快照中文,不依赖 i18n) */ + public static function buildModelName(array $s): string + { + $seriesName = trim((string)($s['series_name'] ?? $s['series_code'] ?? '')); + $yearPart = !empty($s['model_year']) ? ($s['model_year'] . '款 ') : ''; + + $fuel = strtoupper($s['fuel_type_code'] ?? ''); + $tail = ''; + + if ($fuel === 'EV') { + $range = !empty($s['range_km']) ? (int)$s['range_km'] . 'KM ' : ''; + $drive = trim((string)($s['drive_type'] ?? $s['drive_type_code'] ?? '')); + $tail = "EV {$range}{$drive}"; + } elseif ($fuel === 'GAS') { + $disp = (!empty($s['engine_disp_l'])) + ? rtrim(rtrim(number_format((float)$s['engine_disp_l'], 1, '.', ''), '0'), '.') . 'L ' + : ''; + $trans = trim((string)($s['transmission'] ?? $s['transmission_code'] ?? '')); + $tail = "{$disp}{$trans}"; + } else { + $tail = trim((string)($s['fuel_type'] ?? $fuel)); + } + + $extras = []; + foreach (['trim','trim_level','edition_name'] as $k) { + if (!empty($s[$k])) $extras[] = trim((string)$s[$k]); + } + $extraStr = $extras ? ' ' . implode(' ', $extras) : ''; + + return trim($seriesName . ' ' . $yearPart . $tail . $extraStr); + } + + /* ===== Helpers ===== */ + + private static function tok($v): string + { + $v = strtoupper((string)$v); + return preg_replace('/[^A-Z0-9_]/', '', $v ?? ''); + } + + private static function numToken($v): string + { + return preg_replace('/[^0-9]/', '', (string)$v); + } + + /** 1.5L -> 15L;2.0L -> 20L(便于 code 稳定检索) */ + private static function dispToken($l): string + { + $x = (float)$l; + return (string)round($x * 10) . 'L'; + } +} \ No newline at end of file diff --git a/app/common/logic/PermLogic.php b/app/common/logic/PermLogic.php new file mode 100644 index 0000000..808cb15 --- /dev/null +++ b/app/common/logic/PermLogic.php @@ -0,0 +1,144 @@ +first(); + + // 获取用户及其关联角色资源和直接分配的资源 + $user = User::with(['roles.resources', 'resources'])->find($userId); + + if (!$user) { + return collect(); + } + + $all = collect(); + // 遍历用户的所有角色,合并每个角色所拥有的资源 + foreach ($user->roles as $role) { + $all = $all->merge( + // 过滤当前角色的资源列表 + $role->resources->filter(function ($resource) use($system) { + // 资源过滤条件: + // 1. !$resource->no_auth:资源需要权限认证(排除公共资源) + // 2. 系统匹配条件: + // - $system->id == $resource->system_id:资源属于当前系统 + // - $resource->system_id == 0:资源为全局公共资源(所有系统可用) + return !$resource->no_auth && ($system->id == $resource->system_id || $resource->system_id == 0); + }) + ); + } + + // 合并用户直接分配的资源,排除不需要权限认证的资源 + $all = $all->merge( + $user->resources->filter(function ($resource) { + // 只保留需要权限认证的资源(或公共资源) + return !$resource->no_auth; + }) + ); + + // 加入公共菜单,公共菜单不管是否授权,所有人都能访问 + $publicResources = Resource::where('no_auth', 1) + ->orderBy('sort', 'asc') // 按 sort 字段升序排序 + ->get(); + + // 合并所有公共资源 + $all = $all->merge($publicResources); + + // 返回唯一的资源列表,并按 id 排序 + // 使用 sortBy('sort') 来确保最终结果根据 'sort' 字段排序 + return $all->unique('id')->values()->sortBy('sort'); + } + + /** + * 构建目录/菜单树 + * + * @param \Illuminate\Support\Collection $resources 全部资源集合 + * @param int $parentId 当前父级资源 ID + * @param string $parentPath 父级完整路径(用于递归) + * @return array + */ + public function buildTree($resources, int $parentId = 0, string $parentPath = ''): array + { + $tree = []; + foreach ($resources as $node) { + if ($node->parent_id !== $parentId) continue; + + // 拼接完整路径 + $currentPath = $parentPath; + if ($currentPath && substr($currentPath, -1) === '/') { + $currentPath = rtrim($currentPath, '/'); + } + if (!empty($node->url)) { + if (!$currentPath) { + $currentPath = $node->url; + } else { + $url = ltrim($node->url, '/'); + $currentPath = $currentPath . '/' . $url; + } + } + + // 子节点递归 + $children = []; + if ($node->type === 'directory') { + $children = $this->buildTree($resources, $node->id, $currentPath); + } + + // 权限 + $authList = $resources + ->filter(fn($r) => $r->type === 'permission' && $r->parent_id === $node->id) + ->map(fn($r) => [ + 'id' => $r->id, + 'title' => $r->name, + 'auth_mark' => $r->code, + ])->values()->all(); + + // 判断是否有子级菜单(非权限) + $hasChildrenMenu = collect($resources)->where('parent_id', $node->id) + ->whereIn('type', ['directory', 'menu'])->isNotEmpty(); + + $componentValue = $hasChildrenMenu ? '/index/index' : $currentPath; + + $tree[] = [ + 'id' => $node->id, + 'name' => $node->name, + 'path' => $node->url, + 'component' => $componentValue, + 'meta' => [ + 'title' => $node->name, + 'icon' => $node->icon, + 'keepAlive' => $node->keep_alive == 1 ? true : false, + 'authList' => $authList, + 'openType' => $node->open_type, + 'isHide' => $node->is_hide == 0 ? false : true, + 'isHideTab' => $node->is_hide_tab == 0 ? false : true + ], + 'children' => $children, + ]; + } + return $tree; + } + + /** + * 获取用户所有权限code + */ + public function getPermissionCodes(int $userId, string $systemCode): array + { + $resources = $this->getUserResources($userId, $systemCode); + return $resources + ->filter(fn($res) => $res->type === 'permission') + ->pluck('code') + ->unique() + ->values() + ->all(); + } +} \ No newline at end of file diff --git a/app/common/logic/PurchaseOrderItemsLogic.php b/app/common/logic/PurchaseOrderItemsLogic.php new file mode 100644 index 0000000..6c5702d --- /dev/null +++ b/app/common/logic/PurchaseOrderItemsLogic.php @@ -0,0 +1,17 @@ +where('pi_no', '!=', '') + ->select('pi_no', 'salesperson_snapshot') + ->distinct() + ->get(); + } + + /** + * 根据PI号获取备货单 + */ + public function getOrderNoListByPiNo($piNo) + { + return (new StockOrders())->where('pi_no', $piNo) + ->where('approval_status', 2) + ->select('order_no', 'biz_date', 'salesperson_snapshot', 'id as stock_orders_id', 'salesperson_id') + ->get(); + } + + /** + * 根据备货单号获取备货单明细 + */ + public function getOrderItemsListByOrderNo($stockOrdersId) + { + return (new StockOrderItems())->where('order_id', $stockOrdersId) + ->select('model_snapshot', 'color_interior_snapshot', 'color_exterior_snapshot', 'unique_id', 'id as stock_order_items_id') + ->get(); + } +} \ No newline at end of file diff --git a/app/common/logic/ResourceLogic.php b/app/common/logic/ResourceLogic.php new file mode 100644 index 0000000..ddf6423 --- /dev/null +++ b/app/common/logic/ResourceLogic.php @@ -0,0 +1,37 @@ +logicListAll($config); + + if ($request->get('role_id')) { + $selected = (new RoleLogic())->getRoleResources($request->get('role_id')); + } else if ($request->get('user_id')) { + $selected = (new RoleLogic())->getUserResources($request->get('user_id')); + } else { + $selected = []; + } + + foreach ($data as &$item) { + $item['selected'] = in_array($item['id'], $selected) ? true : false; + } + + return $data; + } +} \ No newline at end of file diff --git a/app/common/logic/RoleLogic.php b/app/common/logic/RoleLogic.php new file mode 100644 index 0000000..0898b10 --- /dev/null +++ b/app/common/logic/RoleLogic.php @@ -0,0 +1,85 @@ +where('role_id', $roleId)->delete(); + + // 批量插入新角色资源关联 + if (!empty($resources)) { + $insertData = []; + foreach ($resources as $resourceId) { + $insertData[] = [ + 'role_id' => $roleId, + 'resource_id' => $resourceId + ]; + } + Db::table('role_resources')->insert($insertData); + } + + Db::commit(); + return true; + } catch (\Throwable $exception) { + Db::rollBack(); + return false; + } + } + + /** + * 获取角色资源 + * + * @param int $roleId 角色ID + * @return array 资源ID列表 + */ + public function getRoleResources(int $roleId): array + { + if (empty($roleId)) { + return []; + } + + return Db::table('role_resources') + ->where('role_id', $roleId) + ->pluck('resource_id') + ->toArray(); + } + + /** + * 获取用户资源 + * + * @param int $userId 用户ID + * @return array 资源ID列表 + */ + public function getUserResources(int $userId): array + { + if (empty($userId)) { + return []; + } + + return Db::table('user_resources') + ->where('user_id', $userId) + ->pluck('resource_id') + ->toArray(); + } +} diff --git a/app/common/logic/SkuCodeBuilder.php b/app/common/logic/SkuCodeBuilder.php new file mode 100644 index 0000000..823b8dc --- /dev/null +++ b/app/common/logic/SkuCodeBuilder.php @@ -0,0 +1,233 @@ + 'FLG', + 'PREMIUM' => 'PRM', + 'STANDARD' => 'STD', + 'DELUXE' => 'DLX', + // edition + 'HONOR' => 'HNR', + 'CHAMPION' => 'CHP', + 'ANNIV' => 'ANV', + // trans + '1SPEED' => '1SP', + // drive/fuel/body 等一般已短,不必压缩 + ]; + + /** 入口:生成 sku_code */ + public static function build(array $s, array $opts = []): string + { + $maxLen = $opts['max_len'] ?? 40; + $ensureUnique = $opts['ensure_unique'] ?? true; + + $tokens = [ + 'brand' => self::tok($s['brand_code'] ?? ''), + 'series' => self::tok($s['series_code'] ?? ''), + 'year' => isset($s['model_year']) ? (string)intval($s['model_year']) : '', + 'fuel' => self::tok($s['fuel_type_code'] ?? ''), + 'drive' => self::tok($s['drive_type_code'] ?? ''), + 'spec' => self::buildSpec($s, self::tok($s['fuel_type_code'] ?? '')), + 'level' => self::abbr(self::tok($s['trim_level_code'] ?? '')), + 'edition' => self::abbr(self::tok($s['edition_code'] ?? '')), + ]; + + // 过滤空段并按既定顺序拼接 + $order = ['brand','series','year','fuel','drive','spec','level','edition']; + $parts = []; + foreach ($order as $k) { + if (!empty($tokens[$k])) $parts[$k] = $tokens[$k]; + } + + $sku = self::normalizeSku(implode('-', $parts)); + + if (strlen($sku) > $maxLen) { + $sku = self::shrink($parts, $maxLen); // 传入“带键名的 parts” + } + + if ($ensureUnique) { + $sku = self::ensureUnique($sku, $maxLen); + } + return $sku; + } + + /** 构造规格位(SPEC),按能源类型差异化 */ + private static function buildSpec(array $s, string $fuel): string + { + $fuel = strtoupper($fuel); + if ($fuel === 'EV') { + if (!empty($s['range_km'])) { + $r = (int)$s['range_km']; + $r10 = (int)round($r / 10) * 10; // 到 10 位 + return (string)$r10; + } + if (!empty($s['battery_kwh'])) { + return self::numToken($s['battery_kwh']) . 'KWH'; + } + return ''; + } + + if ($fuel === 'GAS') { + $bits = []; + if (!empty($s['engine_disp_l'])) { + $bits[] = self::dispToken($s['engine_disp_l']); // 1.5L -> 15L + } + if (!empty($s['engine_turbo']) && intval($s['engine_turbo']) === 1) { + $bits[] = 'T'; + } + if (!empty($s['transmission_code'])) { + $bits[] = self::abbr(self::tok($s['transmission_code'])); + } + return implode('', $bits); // 如 15LTCVT + } + + // HEV/PHEV/EREV 等 + $bits = []; + if (!empty($s['engine_disp_l'])) { + $bits[] = self::dispToken($s['engine_disp_l']); // 可空 + } + if (!empty($s['motor_layout_code'])) { + $bits[] = self::tok($s['motor_layout_code']); // 如 AWD / DM-I / DM-P + } + return implode('', $bits); + } + + /** 冲突检测并追加短校验(全局唯一) */ + private static function ensureUnique(string $sku, int $maxLen): string + { + if (!VehVehicleSku::where('sku_code', $sku)->exists()) { + return $sku; + } + // 用 crc32 生成 5~6 位 base36 校验(稳定且短) + $crc = self::base36Crc($sku); + $suffix = '-X' . $crc; + + if (strlen($sku . $suffix) > $maxLen) { + $sku = substr($sku, 0, max(0, $maxLen - strlen($suffix))); + $sku = rtrim($sku, '-'); // 避免尾部 '-' + } + $sku .= $suffix; + + // 若极端情况下仍冲突,追加递增计数 + $i = 2; + $candidate = $sku; + while (VehVehicleSku::where('sku_code', $candidate)->exists()) { + $extra = '-'.$i; + $cut = max(0, $maxLen - strlen($extra)); + $candidate = substr($sku, 0, $cut) . $extra; + $i++; + } + return $candidate; + } + + /** 把 token 数组尽量压到 maxLen(逐步压缩策略) */ + private static function shrink(array $partsByKey, int $maxLen): string + { + // 1) 先对 series/level/edition 做激进压缩 + $p = $partsByKey; + foreach (['series','level','edition'] as $k) { + if (!empty($p[$k])) $p[$k] = self::heavyAbbr($p[$k]); + } + $sku = self::normalizeSku(implode('-', $p)); + if (strlen($sku) <= $maxLen) return $sku; + + // 2) 依次移除不太关键的信息:edition → level(若存在) + if (!empty($p['edition'])) { unset($p['edition']); } + $sku = self::normalizeSku(implode('-', $p)); + if (strlen($sku) <= $maxLen) return $sku; + + if (!empty($p['level'])) { unset($p['level']); } + $sku = self::normalizeSku(implode('-', $p)); + if (strlen($sku) <= $maxLen) return $sku; + + // 3) 压缩 spec(如去 KM/KWH,仅保留核心) + if (!empty($p['spec'])) { $p['spec'] = self::compactSpec($p['spec']); } + $sku = self::normalizeSku(implode('-', $p)); + if (strlen($sku) <= $maxLen) return $sku; + + // 4) 仍超长:从右向左截断次要段(spec/drive/year),保留前三段(brand/series/fuel)优先 + $dropOrder = ['spec','drive','year']; + $q = $p; + foreach ($dropOrder as $k) { + if (!empty($q[$k])) { unset($q[$k]); } + $sku = self::normalizeSku(implode('-', $q)); + if (strlen($sku) <= $maxLen) return $sku; + } + + // 5) 最后兜底:硬截到 maxLen,后续 ensureUnique 会追加 -X 校验并避免冲突 + return substr(self::normalizeSku(implode('-', $p)), 0, $maxLen); + } + + /* ===== Helpers ===== */ + + private static function tok($v): string + { + $v = strtoupper((string)$v); + return preg_replace('/[^A-Z0-9_]/', '', $v ?? ''); + } + + private static function abbr(string $t): string + { + return self::ABBR[$t] ?? $t; + } + + private static function heavyAbbr(string $t): string + { + // 更激进:去除元音,连字符化 + $t = str_replace(['STANDARD','FLAGSHIP','PREMIUM','DELUXE'], ['STD','FLG','PRM','DLX'], $t); + $t = preg_replace('/[AEIOU]/', '', $t); + return $t; + } + + private static function compactSpec(string $s): string + { + // 规则:去 KM/KWH 等单位,只保留数字与核心字母 + $s = strtoupper($s); + $s = str_replace(['KM','KWH'], '', $s); + return $s; + } + + private static function numToken($v): string + { + return preg_replace('/[^0-9]/', '', (string)$v); + } + + /** 1.5L -> 15L;2.0L -> 20L */ + private static function dispToken($l): string + { + $x = (float)$l; + return (string)round($x * 10) . 'L'; + } + + private static function base36Crc(string $s): string + { + $crc = sprintf('%u', crc32($s)); // 无符号 + $n = (int)$crc; + $chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $res = ''; + do { + $res = $chars[$n % 36] . $res; + $n = intdiv($n, 36); + } while ($n > 0); + return substr($res, -6); // 取后6位足够 + } + + private static function normalizeSku(string $s): string + { + // 连续的 '-' 合并,首尾修剪 + $s = preg_replace('/-+/', '-', $s); + $s = trim($s, '-'); + return $s; + } +} \ No newline at end of file diff --git a/app/common/logic/StockOrderAccountsLogic.php b/app/common/logic/StockOrderAccountsLogic.php new file mode 100644 index 0000000..b76c961 --- /dev/null +++ b/app/common/logic/StockOrderAccountsLogic.php @@ -0,0 +1,17 @@ +orderBy('sort','desc')->get(['id', 'account_name', 'currency_id', 'account_number', 'bank_name', 'bank_branch', 'description', 'short_desc']); + } + +} \ No newline at end of file diff --git a/app/common/logic/SysConfigLogic.php b/app/common/logic/SysConfigLogic.php new file mode 100644 index 0000000..633d177 --- /dev/null +++ b/app/common/logic/SysConfigLogic.php @@ -0,0 +1,22 @@ +where('config_key', $key)->get(['id', 'config_key', 'config_value']); + } +} \ No newline at end of file diff --git a/app/common/logic/SysCurrencyLogic.php b/app/common/logic/SysCurrencyLogic.php new file mode 100644 index 0000000..4de7559 --- /dev/null +++ b/app/common/logic/SysCurrencyLogic.php @@ -0,0 +1,21 @@ +get(['id', 'country_id', 'currency_code', 'currency_name', 'currency_symbol', 'exchange_rate', 'float_percent_up', 'float_percent_down']); + } +} \ No newline at end of file diff --git a/app/common/logic/SysDictItemLogic.php b/app/common/logic/SysDictItemLogic.php new file mode 100644 index 0000000..d6efc72 --- /dev/null +++ b/app/common/logic/SysDictItemLogic.php @@ -0,0 +1,22 @@ +where($where)->orderBy('sort','desc')->get(['id', 'item_label', 'item_value', 'remark', 'dict_id']); + } + +} \ No newline at end of file diff --git a/app/common/logic/SysDictLogic.php b/app/common/logic/SysDictLogic.php new file mode 100644 index 0000000..6447ed3 --- /dev/null +++ b/app/common/logic/SysDictLogic.php @@ -0,0 +1,27 @@ +where('code', $code)->orderBy('sort','desc')->first(['id']); + } + + public function getList() + { + return SysDict::where('status', 1)->get(['id', 'dict_name', 'dict_code', 'description']); + } + +} \ No newline at end of file diff --git a/app/common/logic/SysOpLogLogic.php b/app/common/logic/SysOpLogLogic.php new file mode 100644 index 0000000..ec454cd --- /dev/null +++ b/app/common/logic/SysOpLogLogic.php @@ -0,0 +1,17 @@ +where($where)->orderBy('sort','desc')->get(['id', 'port_code', 'port_name', 'port_name_en', 'country_id', 'description']); + } + +} \ No newline at end of file diff --git a/app/common/logic/SysUpdateLogLogic.php b/app/common/logic/SysUpdateLogLogic.php new file mode 100644 index 0000000..6bec427 --- /dev/null +++ b/app/common/logic/SysUpdateLogLogic.php @@ -0,0 +1,17 @@ +orderBy('sort', 'asc')->get(['id', 'name', 'code', 'type', 'type']); + } +} \ No newline at end of file diff --git a/app/common/logic/UploadedFilesLogic.php b/app/common/logic/UploadedFilesLogic.php new file mode 100644 index 0000000..3490cf2 --- /dev/null +++ b/app/common/logic/UploadedFilesLogic.php @@ -0,0 +1,169 @@ + $this->modelClass, + 'where' => ['partition' => $partition], + 'order' => ['created_at' => 'desc', 'id' => 'desc'] + ]; + + return $this->logicList($config, $limit, $page); + } + + /** + * 根据分类获取文件列表 + * + * @param string $category + * @param int $limit + * @param int $page + * @return mixed + */ + public function getFilesByCategory(string $category, int $limit = 15, int $page = 1) + { + $config = [ + 'model' => $this->modelClass, + 'where' => ['category' => $category], + 'order' => ['created_at' => 'desc', 'id' => 'desc'] + ]; + + return $this->logicList($config, $limit, $page); + } + + /** + * 根据文件类型获取文件列表 + * + * @param string $fileType + * @param int $limit + * @param int $page + * @return mixed + */ + public function getFilesByType(string $fileType, int $limit = 15, int $page = 1) + { + $config = [ + 'model' => $this->modelClass, + 'where' => ['file_type' => $fileType], + 'order' => ['created_at' => 'desc', 'id' => 'desc'] + ]; + + return $this->logicList($config, $limit, $page); + } + + /** + * 搜索文件 + * + * @param string $keyword + * @param int $limit + * @param int $page + * @return mixed + */ + public function searchFiles(string $keyword, int $limit = 15, int $page = 1) + { + $config = [ + 'model' => $this->modelClass, + 'where' => [ + 'or' => [ + ['file_name', 'like', '%' . $keyword . '%'], + ['original_name', 'like', '%' . $keyword . '%'], + ['category', 'like', '%' . $keyword . '%'] + ] + ], + 'order' => ['created_at' => 'desc', 'id' => 'desc'] + ]; + + return $this->logicList($config, $limit, $page); + } + + /** + * 获取文件统计信息 + * + * @return array + */ + public function getFileStatistics(): array + { + $model = new $this->modelClass(); + + $totalFiles = $model->count(); + $totalSize = $model->sum('file_size'); + + $partitionStats = $model->selectRaw('partition, COUNT(*) as count, SUM(file_size) as size') + ->groupBy('partition') + ->get() + ->toArray(); + + $categoryStats = $model->selectRaw('category, COUNT(*) as count, SUM(file_size) as size') + ->groupBy('category') + ->get() + ->toArray(); + + $typeStats = $model->selectRaw('file_type, COUNT(*) as count, SUM(file_size) as size') + ->groupBy('file_type') + ->get() + ->toArray(); + + return [ + 'total_files' => $totalFiles, + 'total_size' => $totalSize, + 'partition_stats' => $partitionStats, + 'category_stats' => $categoryStats, + 'type_stats' => $typeStats + ]; + } + + /** + * 清理过期文件 + * + * @param int $days 保留天数 + * @return int 清理的文件数量 + */ + public function cleanExpiredFiles(int $days = 30): int + { + $expiredTime = time() - ($days * 24 * 60 * 60); + + $config = [ + 'model' => $this->modelClass, + 'where' => [ + ['created_at', '<', $expiredTime] + ] + ]; + + $files = $this->logicListAll($config); + $count = 0; + + foreach ($files as $file) { + // 删除物理文件 + $fullPath = public_path() . $file->file_path; + if (file_exists($fullPath)) { + unlink($fullPath); + } + + // 删除数据库记录 + $this->logicDeleteBy(['where' => ['id' => $file->id]]); + $count++; + } + + return $count; + } +} \ No newline at end of file diff --git a/app/common/logic/UsedcarCrawlResultsImagesLogic.php b/app/common/logic/UsedcarCrawlResultsImagesLogic.php new file mode 100644 index 0000000..428bc16 --- /dev/null +++ b/app/common/logic/UsedcarCrawlResultsImagesLogic.php @@ -0,0 +1,17 @@ + 0) { + foreach ($data['user_ids'] as $member) { + $this->addUserGroupMembers($member, $created->id); + } + } + if ((int)$data['leader_id'] > 0) { + $leader = UserGroupMembers::where('user_id', $data['leader_id'])->where('group_id', $created->id)->first(); + if ($leader) { + $leader->is_leader = 1; + $leader->save(); + } else { + $this->addUserGroupMembers($data['leader_id'], $created->id, 1); + } + } + return $created; + } + + + public function updateGroup(UserGroup $userGroup, array $data) + { + $userGroup->title = $data['title']; + $userGroup->sort = $data['sort'] ?? 0; + $userGroup->status = $data['status']; + $userGroup->scenario_code = $data['scenario_code'] ?? ''; + $userGroup->save(); + + // 删除所有成员 + UserGroupMembers::where('group_id', $userGroup->id)->delete(); + + if (isset($data['user_ids']) && is_array($data['user_ids']) && count($data['user_ids']) > 0) { + foreach ($data['user_ids'] as $member) { + $this->addUserGroupMembers($member, $userGroup->id); + } + } + if ((int)$data['leader_id'] > 0) { + $leader = UserGroupMembers::where('user_id', $data['leader_id'])->where('group_id', $userGroup->id)->first(); + if ($leader) { + $leader->is_leader = 1; + $leader->save(); + } else { + $this->addUserGroupMembers($data['leader_id'], $userGroup->id, 1); + } + } + return $userGroup; + } + + + public function addUserGroupMembers(int $userId, int $groupId, int $isLeader = 0) + { + $udata = [ + 'user_id' => $userId, + 'group_id' => $groupId, + 'is_leader' => $isLeader + ]; + return UserGroupMembers::create($udata); + } +} \ No newline at end of file diff --git a/app/common/logic/UserGroupMembersLogic.php b/app/common/logic/UserGroupMembersLogic.php new file mode 100644 index 0000000..831c3ca --- /dev/null +++ b/app/common/logic/UserGroupMembersLogic.php @@ -0,0 +1,22 @@ +delete(); + } +} \ No newline at end of file diff --git a/app/common/logic/UserLogic.php b/app/common/logic/UserLogic.php new file mode 100644 index 0000000..55e9608 --- /dev/null +++ b/app/common/logic/UserLogic.php @@ -0,0 +1,110 @@ +find($uid); + + if (!$user) { + throw new BusinessException('账户不存在', 404); + } + + if ($user->status != 1) { + throw new BusinessException('账号已禁用', 403); + } + + return $user; + } + + /** + * 用户信息 + */ + public function edit(int $uid, array $data) + { + // + $user = User::find($uid); + + if (!$user) { + throw new BusinessException('账户不存在', 404); + } + + $user->fill($data); + $user->save(); + + // 绑定内勤 10.13 add + if (isset($data['office_clerk_id']) && $data['office_clerk_id'] > 0) { + $ug = UserGroupMembers::where('user_id', $uid)->where('status', 1)->first(); + if ($ug) { + $ug->delete(); + } + + $group = UserGroupMembers::where('user_id', $data['office_clerk_id'])->where('is_leader', 1)->where('status', 1)->first(); + if (!$group) { + throw new BusinessException('该内勤还未创建分组', 404); + } + + UserGroupMembers::create([ + 'user_id' => $uid, + 'group_id' => $group->group_id, + 'is_leader' => 0, + 'status' => 1 + ]); + } + + return $user; + } + + /** + * 修改密码 + */ + public function savePassword(int $uid, array $data) + { + $password = password_hash($data['password_new'], PASSWORD_DEFAULT); + $res = User::where('id',$uid)->update(['password' => $password]); + + return $res; + } + + /** + * 获取用户列表 + */ + public function getList(Request $request) + { + $p = $request->all(); + + $user = User::where('status', 1); + if (isset($p['role_id']) && $p['role_id'] > 0) { + $userIds = UserRoles::where('role_id', $p['role_id'])->pluck('user_id'); + $user->whereIn('id', $userIds); + } + $list = $user->orderBy('id', 'desc')->get(); + $arr = []; + foreach ($list->toArray() as $item) { + $arr[] = [ + 'name' => $item['name'], + 'id' => $item['id'], + ]; + } + return $arr; + } +} \ No newline at end of file diff --git a/app/common/logic/VehColorLogic.php b/app/common/logic/VehColorLogic.php new file mode 100644 index 0000000..7a36811 --- /dev/null +++ b/app/common/logic/VehColorLogic.php @@ -0,0 +1,22 @@ +where($where)->orderBy('sort','desc')->get(['id', 'color_code', 'color_text']); + } + +} \ No newline at end of file diff --git a/app/common/logic/VehDictI18nLogic.php b/app/common/logic/VehDictI18nLogic.php new file mode 100644 index 0000000..f25b50b --- /dev/null +++ b/app/common/logic/VehDictI18nLogic.php @@ -0,0 +1,17 @@ +orderBy('sort','desc')->get(['id', 'code', 'name', 'type', 'price']); + } + +} \ No newline at end of file diff --git a/app/common/logic/VehMoneyValueLogic.php b/app/common/logic/VehMoneyValueLogic.php new file mode 100644 index 0000000..5006eaa --- /dev/null +++ b/app/common/logic/VehMoneyValueLogic.php @@ -0,0 +1,17 @@ +orderBy('id','desc')->get(['id', 'sku_code', 'brand_code', 'brand_code', 'series_code', 'series_name', 'model_code', 'model_name', 'model_year', 'msrp']); + } +} \ No newline at end of file diff --git a/app/common/logic/VehVehicleUnitLogic.php b/app/common/logic/VehVehicleUnitLogic.php new file mode 100644 index 0000000..45a1038 --- /dev/null +++ b/app/common/logic/VehVehicleUnitLogic.php @@ -0,0 +1,17 @@ + 'array', + ]; +} \ No newline at end of file diff --git a/app/common/model/ApprovalInstances.php b/app/common/model/ApprovalInstances.php new file mode 100644 index 0000000..e683eb4 --- /dev/null +++ b/app/common/model/ApprovalInstances.php @@ -0,0 +1,122 @@ + 'array', // 自动转换 JSON 字段为数组 + 'finished_at' => 'datetime', + 'deadline_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + + + // ====== ====== ====== 映射业务表 ====== ====== ====== + protected const BIZ_CLASS = [ + 'stock_orders' => \app\common\model\StockOrders::class, + 'stock_orders_nullify' => \app\common\model\StockOrders::class, + 'purchase_orders' => \app\common\model\PurchaseOrders::class, + ]; + + public function getBizTypeAttribute($value) + { + return self::BIZ_CLASS[$value] ?? $value; + } + + public function bizInfo() + { + return $this->morphTo('bizInfo', 'biz_type', 'biz_id')->withTrashed(); + } + // ====== ====== ====== 映射表业务 ====== ====== ====== + + + + public function records() + { + return $this->hasMany(ApprovalRecords::class, 'instance_id', 'id'); + } + + public function flows() + { + return $this->belongsTo(ApprovalFlows::class, 'flow_type', 'type'); + } + + public function initiator() + { + return $this->belongsTo(User::class, 'initiator_id', 'id'); + } + + // public function bizInfo2() + // { + // return $this->belongsTo(StockOrders::class, 'biz_id', 'id'); + // } + + /** + * 获取当前步骤的所有审批记录(pending 状态) + */ + public function currentStepRecords() + { + return $this->approvalRecords()->where('step_index', $this->current_step)->where('status', 'pending'); + } +} \ No newline at end of file diff --git a/app/common/model/ApprovalRecords.php b/app/common/model/ApprovalRecords.php new file mode 100644 index 0000000..88a820e --- /dev/null +++ b/app/common/model/ApprovalRecords.php @@ -0,0 +1,79 @@ + 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function instance() + { + return $this->belongsTo(ApprovalInstances::class, 'instance_id', 'id'); + } + + + public function approver() + { + return $this->belongsTo(User::class, 'approver_id', 'id'); + } +} \ No newline at end of file diff --git a/app/common/model/Brand.php b/app/common/model/Brand.php new file mode 100644 index 0000000..22c4704 --- /dev/null +++ b/app/common/model/Brand.php @@ -0,0 +1,49 @@ +hasOne(Corp::class, 'id', 'corp_id'); + } + + public function systemInfo() + { + return $this->hasOne(Systems::class, 'id', 'system_id'); + } +} \ No newline at end of file diff --git a/app/common/model/Country.php b/app/common/model/Country.php new file mode 100644 index 0000000..80439a4 --- /dev/null +++ b/app/common/model/Country.php @@ -0,0 +1,49 @@ +code)) { + $customer->code = self::generateCustomerCode($customer); + // 使用saveQuietly避免递归调用 + $customer->saveQuietly(); + } + }); + } + + public static function generateCustomerCode($customer) + { + // 生成唯一16位code,CM+14位 + $unique = strtoupper(substr(md5($customer->id . microtime(true) . mt_rand(1000, 9999)), 8, 6)); + $code = 'CM' . date('Ymd') .$unique; + return $code; + } + + // 静默保存方法,不触发事件 + public function saveQuietly(array $options = []) + { + return static::withoutEvents(function () use ($options) { + return $this->save($options); + }); + } +} \ No newline at end of file diff --git a/app/common/model/CustomerAudit.php b/app/common/model/CustomerAudit.php new file mode 100644 index 0000000..4e52267 --- /dev/null +++ b/app/common/model/CustomerAudit.php @@ -0,0 +1,49 @@ +hasOne(FinReceipt::class, 'payment_apply_id', 'id'); + } + + /** + * 关联的备货单(通过中间表) + */ + public function stocks() + { + return $this->belongsToMany(StockOrders::class, 'fin_payment_stock', 'payment_apply_id', 'stock_id'); + } + + /** + * 查询 submit_files 不为空的记录 + */ + public function scopeWithNonEmptyFiles($query) + { + return $query->whereNotNull('submit_files') + ->whereRaw('JSON_LENGTH(submit_files) > 0'); + } +} \ No newline at end of file diff --git a/app/common/model/FinPaymentStock.php b/app/common/model/FinPaymentStock.php new file mode 100644 index 0000000..d5b3d10 --- /dev/null +++ b/app/common/model/FinPaymentStock.php @@ -0,0 +1,67 @@ +belongsTo(FinPaymentApply::class, 'payment_apply_id', 'id'); + } + + // 收款记录 + public function receipt() + { + return $this->belongsTo(FinReceipt::class, 'receipt_id', 'id'); + } + + // 备货单 + public function stock() + { + return $this->belongsTo(StockOrders::class, 'stock_id', 'id'); + } +} \ No newline at end of file diff --git a/app/common/model/FinReceipt.php b/app/common/model/FinReceipt.php new file mode 100644 index 0000000..88d3090 --- /dev/null +++ b/app/common/model/FinReceipt.php @@ -0,0 +1,81 @@ +belongsTo(FinPaymentApply::class, 'payment_apply_id', 'id'); + } + + /** + * 关联的备货单(通过中间表) + */ + public function stocks() + { + return $this->belongsToMany(StockOrders::class, 'fin_payment_stock', 'receipt_id', 'stock_id'); + } + + /** + * 关联付款申请 + */ +// public function finPaymentApply() +// { +// return $this->hasOne(FinPaymentApply::class, 'receipt_id', 'id'); +// } + + /** + * 关联付款申请表 + */ + public function paymentApply() + { + return $this->belongsTo(FinPaymentApply::class, 'payment_apply_id', 'id'); + } +} \ No newline at end of file diff --git a/app/common/model/FinReceiptHis.php b/app/common/model/FinReceiptHis.php new file mode 100644 index 0000000..8a036b3 --- /dev/null +++ b/app/common/model/FinReceiptHis.php @@ -0,0 +1,49 @@ +hasOne(User::class, 'id', 'enter_user_id'); + } + + // public function recipientInfo() + // { + // return $this->hasOne(User::class, 'id', 'recipient')->where('status', 1); + // } + public function recipientInfo() + { + return $this->hasOne(User::class, 'id', 'recipient'); + } + + public function channelInfo() + { + return $this->hasOne(InquiryChannel::class, 'id', 'channel'); + } + + public function countryInfo() + { + return $this->hasOne(Country::class, 'id', 'country'); + } +} \ No newline at end of file diff --git a/app/common/model/InquiryTransmit.php b/app/common/model/InquiryTransmit.php new file mode 100644 index 0000000..b02048d --- /dev/null +++ b/app/common/model/InquiryTransmit.php @@ -0,0 +1,49 @@ + '系统消息', + self::SCENE1 => '系统通知' + ]; + const SCENE0 = 0; + const SCENE1 = 1; + + // 推送方式 + const PUSH_METHOD = [ + self::PUSH_METHOD0 => '系统推送', + self::PUSH_METHOD1 => '手动推送' + ]; + const PUSH_METHOD0 = 0; + const PUSH_METHOD1 = 1; + + + /** + * 关联用户消息状态 (一对多) + */ + public function userStatuses() + { + return $this->hasMany(MessageUserStatus::class, 'msg_id', 'id'); + } + + /** + * 获取已读用户状态 + */ + public function readUsers() + { + return $this->hasMany(MessageUserStatus::class, 'msg_id', 'id') + ->where('status', 1); + } + + /** + * 获取未读用户状态 + */ + public function unreadUsers() + { + return $this->hasMany(MessageUserStatus::class, 'msg_id', 'id') + ->where('status', 0); + } + + public function sender() + { + return $this->belongsTo(User::class, 'sender_id', 'id'); + } +} \ No newline at end of file diff --git a/app/common/model/MessageUserStatus.php b/app/common/model/MessageUserStatus.php new file mode 100644 index 0000000..0e73ef5 --- /dev/null +++ b/app/common/model/MessageUserStatus.php @@ -0,0 +1,66 @@ +belongsTo(MessageNotifications::class, 'msg_id', 'id'); + } + + /** + * 关联用户 (假设你有 User 模型) + */ + public function user() + { + return $this->belongsTo(User::class, 'uid', 'id'); + } +} \ No newline at end of file diff --git a/app/common/model/PurchaseOrderItems.php b/app/common/model/PurchaseOrderItems.php new file mode 100644 index 0000000..3648865 --- /dev/null +++ b/app/common/model/PurchaseOrderItems.php @@ -0,0 +1,71 @@ +belongsTo(PurchaseOrders::class, 'purchase_order_id', 'id'); + } + + + public function stockOrder() + { + return $this->belongsTo(StockOrders::class, 'stock_id', 'id'); + } + + public function stockOrderItems() + { + return $this->belongsTo(StockOrderItems::class, 'stock_items_id', 'id'); + } +} \ No newline at end of file diff --git a/app/common/model/PurchaseOrders.php b/app/common/model/PurchaseOrders.php new file mode 100644 index 0000000..f51349a --- /dev/null +++ b/app/common/model/PurchaseOrders.php @@ -0,0 +1,64 @@ +hasMany(PurchaseOrderItems::class, 'purchase_order_id', 'id'); + } + + public function buyer() + { + return $this->hasOne(User::class, 'id', 'buyer_id'); + } +} \ No newline at end of file diff --git a/app/common/model/Resource.php b/app/common/model/Resource.php new file mode 100644 index 0000000..bff7b0a --- /dev/null +++ b/app/common/model/Resource.php @@ -0,0 +1,49 @@ +belongsToMany(User::class, 'user_roles', 'role_id', 'user_id'); + } + + // 角色拥有的资源(权限点、菜单) + public function resources() + { + return $this->belongsToMany(Resource::class, 'role_resources', 'role_id', 'resource_id'); + } +} diff --git a/app/common/model/SalesAnnualTargets.php b/app/common/model/SalesAnnualTargets.php new file mode 100644 index 0000000..387ceaf --- /dev/null +++ b/app/common/model/SalesAnnualTargets.php @@ -0,0 +1,49 @@ +belongsTo(StockOrders::class, 'order_id', 'id'); + } +} \ No newline at end of file diff --git a/app/common/model/StockOrderItemOptions.php b/app/common/model/StockOrderItemOptions.php new file mode 100644 index 0000000..e6f8122 --- /dev/null +++ b/app/common/model/StockOrderItemOptions.php @@ -0,0 +1,58 @@ +belongsTo(StockOrderItems::class, 'order_item_id', 'id'); + } +} \ No newline at end of file diff --git a/app/common/model/StockOrderItems.php b/app/common/model/StockOrderItems.php new file mode 100644 index 0000000..f45c532 --- /dev/null +++ b/app/common/model/StockOrderItems.php @@ -0,0 +1,99 @@ +moneyLatest('unit_price')?->amount; } + public function getHandlingFeeAmountAttribute() { return $this->moneyLatest('handling_fee')?->amount; } + public function getFobTransportFeeAmountAttribute() { return $this->moneyLatest('fob_transport_fee')?->amount; } + public function getPortMiscFeeAmountAttribute() { return $this->moneyLatest('port_misc_fee')?->amount; } + public function getRetrofitFeeAmountAttribute() { return $this->moneyLatest('retrofit_fee')?->amount; } + public function getTotalPriceAmountAttribute() { return $this->moneyLatest('total_price')?->amount; } + + + /** + * 关联主订单 + */ + public function order() + { + return $this->belongsTo(StockOrders::class, 'order_id', 'id'); + } + + /** + * 关联订单项选项 + */ + public function options() + { + return $this->hasMany(StockOrderItemOptions::class, 'order_item_id', 'id'); + } + + + /** + * 关联货币价值信息(一个订单项可以有多个货币价值记录) + */ + public function moneyValues() + { + return $this->hasMany(VehMoneyValue::class, 'object_id', 'id') + ->where('object_scene', 'stock_order_items'); // 场景标识区分不同对象类型 + } +} \ No newline at end of file diff --git a/app/common/model/StockOrders.php b/app/common/model/StockOrders.php new file mode 100644 index 0000000..cc2d076 --- /dev/null +++ b/app/common/model/StockOrders.php @@ -0,0 +1,126 @@ +hasMany(StockOrderItems::class, 'order_id', 'id'); + } + + /** + * 关联订单账户信息 + */ + public function accounts() + { + return $this->hasMany(StockOrderAccounts::class, 'order_id', 'id'); + } + + /** + * 关联货币价值信息(一个订单可以有多个货币价值记录) + */ + public function moneyValues() + { + return $this->hasMany(VehMoneyValue::class, 'object_id', 'id')->where('object_scene', 'stock_orders'); + } + + /** + * 关联订单文件附件 + */ + public function files() + { + return $this->hasMany(StockOrdersFiles::class, 'order_id', 'id'); + } + + + public function salesperson() + { + return $this->hasOne(User::class, 'id', 'salesperson_id'); + } + + /** + * 客户的标签 + */ + public function payment() + { + return $this->belongsToMany(FinPaymentApply::class, 'fin_payment_stock', 'stock_id', 'payment_apply_id'); + } + + /** + * 关联收款记录(多对多,经由中间表) + */ + public function receipts() + { + return $this->belongsToMany(FinReceipt::class, 'fin_payment_stock', 'stock_id', 'receipt_id'); + } +} \ No newline at end of file diff --git a/app/common/model/StockOrdersFiles.php b/app/common/model/StockOrdersFiles.php new file mode 100644 index 0000000..3b78e5c --- /dev/null +++ b/app/common/model/StockOrdersFiles.php @@ -0,0 +1,60 @@ + '合同', + self::TRANSACTION_SLIP => '水单', + self::PI_DOC => 'PI', + ]; +} \ No newline at end of file diff --git a/app/common/model/SysAccount.php b/app/common/model/SysAccount.php new file mode 100644 index 0000000..38e48fa --- /dev/null +++ b/app/common/model/SysAccount.php @@ -0,0 +1,49 @@ +hasMany(SysDictItem::class, 'dict_id', 'id'); + } + + + public function category() + { + return $this->belongsTo(SysDictCategory::class, 'category_id', 'id'); + } + + /** + * 一个字典有多个字典项 + */ + public function items() + { + return $this->hasMany(SysDictItem::class, 'dict_id', 'id'); + } + + /** + * 通过 dict_code 快速获取所有 item_value + */ + public function scopeGetItemValuesByCode($query, $dictCode) + { + return $query->where('dict_code', $dictCode) + ->with('items') // 预加载关联项 + ->first()?->items->pluck('item_value'); // 返回 item_value 数组 + } + + /** + * 通过 dict_code 获取完整字典项(含 label + value) + */ + public function scopeGetItemsByCode($query, $dictCode) + { + return $query->where('dict_code', $dictCode) + ->with('items') + ->first()?->items; + } +} \ No newline at end of file diff --git a/app/common/model/SysDictCategory.php b/app/common/model/SysDictCategory.php new file mode 100644 index 0000000..cbca49a --- /dev/null +++ b/app/common/model/SysDictCategory.php @@ -0,0 +1,58 @@ +hasMany(SysDict::class, 'category_id', 'id') + ->where('status', 1) + ->whereNull('deleted_at') + ->orderBy('id', 'asc'); + } +} \ No newline at end of file diff --git a/app/common/model/SysDictItem.php b/app/common/model/SysDictItem.php new file mode 100644 index 0000000..8fbceb9 --- /dev/null +++ b/app/common/model/SysDictItem.php @@ -0,0 +1,67 @@ +belongsTo(SysDict::class, 'dict_id', 'id'); + } + + /** + * 字典项属于一个字典 + */ + public function dict() + { + return $this->belongsTo(SysDict::class, 'dict_id', 'id'); + } +} \ No newline at end of file diff --git a/app/common/model/SysDictItemTranslation.php b/app/common/model/SysDictItemTranslation.php new file mode 100644 index 0000000..8d33913 --- /dev/null +++ b/app/common/model/SysDictItemTranslation.php @@ -0,0 +1,49 @@ +belongsToMany(Role::class, 'user_roles', 'user_id', 'role_id'); + } + + // 用户直接拥有的资源(用于特殊补权) + public function resources() + { + return $this->belongsToMany(Resource::class, 'user_resources', 'user_id', 'resource_id')->orderBy('sort', 'asc'); + } + + // 用户可访问的系统 + public function systems() + { + return $this->belongsToMany(Systems::class, 'user_systems', 'user_id', 'system_id'); + } + + // 账号查询的作用域 + public function scopeByAccount($query, $account) + { + return $query->where('sn', $account) + ->orWhere('phone', $account) + ->orWhere('email', $account); + } + + + public function reportToInfo() + { + return $this->hasOne(User::class, 'id', 'report_to'); + } +} \ No newline at end of file diff --git a/app/common/model/UserGroup.php b/app/common/model/UserGroup.php new file mode 100644 index 0000000..02d91c0 --- /dev/null +++ b/app/common/model/UserGroup.php @@ -0,0 +1,56 @@ +hasMany(UserGroupMembers::class, 'group_id', 'id'); + } + +} \ No newline at end of file diff --git a/app/common/model/UserGroupMembers.php b/app/common/model/UserGroupMembers.php new file mode 100644 index 0000000..7d3d57a --- /dev/null +++ b/app/common/model/UserGroupMembers.php @@ -0,0 +1,60 @@ +hasOne(User::class, 'id', 'user_id'); + } + + public function group() + { + return $this->hasOne(UserGroup::class, 'id', 'group_id'); + } +} \ No newline at end of file diff --git a/app/common/model/UserRoles.php b/app/common/model/UserRoles.php new file mode 100644 index 0000000..97005ce --- /dev/null +++ b/app/common/model/UserRoles.php @@ -0,0 +1,47 @@ + 'integer', + 'enabled' => 'integer', + 'sort_order' => 'integer', + 'created_at' => 'integer', + 'updated_at' => 'integer', + ]; + + protected $fillable = [ + 'field','dict','required','enabled','sort_order', + 'remark','created_at','updated_at' + ]; +} \ No newline at end of file diff --git a/app/common/model/VehModification.php b/app/common/model/VehModification.php new file mode 100644 index 0000000..d3bd16e --- /dev/null +++ b/app/common/model/VehModification.php @@ -0,0 +1,49 @@ + 'string', + 'exchange_rate' => 'string', + ]; + + /** 按本行汇率换到结算币;无汇率返回 null */ + public function amountInSettlement(int $scale = 2): ?string + { + if ($this->exchange_rate === null) return null; + return (string) bcmul((string)$this->amount, (string)$this->exchange_rate, $scale); + } + + + /** + * 关联订单 + */ + public function order() + { + return $this->belongsTo(StockOrders::class, 'object_id', 'id'); + } + + /** + * 关联订单项(一个货币价值记录可能关联到订单项) + */ + public function orderItem() + { + return $this->belongsTo(StockOrderItems::class, 'object_id', 'id'); + } + + /** + * 限定查询特定对象类型的货币价值记录 + */ + public function scopeOfScene($query, $scene) + { + return $query->where('object_scene', $scene); + } +} \ No newline at end of file diff --git a/app/common/model/VehMoneyValue_bak.php b/app/common/model/VehMoneyValue_bak.php new file mode 100644 index 0000000..50de155 --- /dev/null +++ b/app/common/model/VehMoneyValue_bak.php @@ -0,0 +1,49 @@ + 'array', + 'features' => 'array', + 'dimensions_mm' => 'array', + 'model_year' => 'integer', + 'engine_turbo' => 'integer', + 'gears' => 'integer', + 'range_km' => 'integer', + 'doors' => 'integer', + 'seats' => 'integer', + 'weight_kg' => 'integer', + 'msrp' => 'float', + 'battery_kwh' => 'float', + 'engine_disp_l' => 'float', + 'is_active' => 'integer', + 'corp_id' => 'integer', + 'created_at' => 'integer', + 'updated_at' => 'integer', + ]; + + protected $fillable = [ + 'sku_code','fingerprint', + 'brand_code','brand_name', + 'series_code','series_name', + 'model_code','model_name','model_year', + 'trim_code','trim_level_code','edition_code', + 'trim','trim_level','edition_name', + 'body_type_code','drive_type_code','motor_layout_code', + 'fuel_type_code','engine_disp_l','engine_turbo','engine_power_kw', + 'battery_kwh','transmission_code','gears', + 'range_km','range_std_code', + 'doors','seats','weight_kg','dimensions_mm', + 'color_exterior_code','color_exterior_text', + 'color_interior_code','color_interior_text', + 'options_official','features','emission_std_code', + 'origin_country','currency','msrp', + 'is_active','corp_id','remark', + 'created_by','updated_by','created_at','updated_at', + 'body_type', 'drive_type', 'motor_layout', 'fuel_type', 'transmission', + 'range_std', 'emission_std' + ]; +} \ No newline at end of file diff --git a/app/common/model/VehVehicleUnit.php b/app/common/model/VehVehicleUnit.php new file mode 100644 index 0000000..28e38f1 --- /dev/null +++ b/app/common/model/VehVehicleUnit.php @@ -0,0 +1,49 @@ +validator; + } + + + protected function buildQueryConfig(Request $request, string $scene = 'index'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'index': + $config['order']['id'] = 'desc'; + break; + case 'show': + $config['where']['id'] = $p['id']; + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } + + + +} \ No newline at end of file diff --git a/app/common/service/ApprovalInstancesService.php b/app/common/service/ApprovalInstancesService.php new file mode 100644 index 0000000..5096321 --- /dev/null +++ b/app/common/service/ApprovalInstancesService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/ApprovalRecordsService.php b/app/common/service/ApprovalRecordsService.php new file mode 100644 index 0000000..61c026e --- /dev/null +++ b/app/common/service/ApprovalRecordsService.php @@ -0,0 +1,194 @@ +validator; + } + + + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'index': + $config['order']['id'] = 'desc'; + + $config['with'] = [ + 'instance', + 'approver', + 'instance.flows', + 'instance.initiator', + 'instance.bizInfo' + ]; + + // $config['dataLimit']['type'] = 'relation'; + // $config['dataLimit']['userId'] = $request->user->uid; + // $config['dataLimit']['userField'] = 'approver_id'; + break; + case 'todo': + + if ($p['scene'] === 'todo') { + if (!in_array(1, $request->user->role_ids)) { + $config['where']['approver_id'] = $request->user->uid; + } + $config['where']['status'] = 'pending'; + $config['where']['instance.status'] = 'processing'; + } + + if ($p['scene'] === 'history') { + if (!in_array(1, $request->user->role_ids)) { + $config['where']['approver_id'] = $request->user->uid; + } + $config['where']['status'] = ['!=', 'pending']; + } + + $config['order']['id'] = 'desc'; + + $config['with'] = [ + 'instance', + 'approver', + 'instance.flows', + 'instance.initiator', + 'instance.bizInfo' + ]; + break; + case 'show': + $config['where']['id'] = $p['id']; + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + case 'logs': + $config['where']['instance_id'] = $p['instance_id']; + $config['order']['id'] = 'desc'; + break; + case 'border': + $config['where']['id'] = $p['id']; + $config['order']['id'] = 'desc'; + $config['with'] = [ + 'instance', + 'approver', + 'instance.flows', + 'instance.initiator', + 'instance.bizInfo' + ]; + break; + } + + return $config; + } + + + public function serviceApprove(Request $request) + { + $param = $request->all(); + $appro = new \app\common\utils\approval\service\ApprovalFlowService(); + return $appro->approve($param['instance_id'], $request->user->uid, $param['action'], $param['comment']); + } + + + public function serviceLogs(Request $request) + { + $config = $this->buildQueryConfig($request, 'logs'); + $item = $this->logicListAll($config); + return $this->postProcess('Logs', $item); + } + + + public function serviceBusinessOrder(Request $request, array $data = []) + { + $config = $this->buildQueryConfig($request, 'border'); + $config = $this->borderConfig($config, $data['flow_type']); + $item = $this->logicShow($config); + $bizInfo = []; + $service = match ($data['flow_type']) { + 'stock_orders' => new \app\common\service\StockOrdersService(), + 'stock_orders_nullify' => new \app\common\service\StockOrdersService(), + 'purchase_orders' => new \app\common\service\PurchaseOrdersService(), + default => new \app\common\service\StockOrdersService() + }; + $bizInfo = $service->formatData($item->instance->bizInfo); + return $this->postProcess('Border', $bizInfo); + } + + public function borderConfig($config, $type = 'stock_orders') + { + $with = match ($type) { + 'stock_orders' => [ + 'instance.bizInfo.items', + 'instance.bizInfo.items.options', + 'instance.bizInfo.items.moneyValues', + 'instance.bizInfo.accounts', + 'instance.bizInfo.moneyValues', + 'instance.bizInfo.salesperson', + ], + 'stock_orders_nullify' => [ + 'instance.bizInfo.items', + 'instance.bizInfo.items.options', + 'instance.bizInfo.items.moneyValues', + 'instance.bizInfo.accounts', + 'instance.bizInfo.moneyValues', + 'instance.bizInfo.salesperson', + ], + 'purchase_orders' => [ + 'instance.bizInfo.items', + 'instance.bizInfo.items.stockOrder', + 'instance.bizInfo.items.stockOrderItems' + ] + }; + $config['with'] = array_merge($config['with'], $with); + return $config; + } + + + public function transferApproval(Request $request, array $data = []) + { + $service = new \app\common\utils\approval\service\ApprovalFlowService(); + $result = $service->transferApproval($data['instance_id'], $data['step_index'], $request->user->uid, $data['to_uid'], $data['reason'], 'name'); + return $result; + } + + + public function serviceApprovalTimeline(Request $request, array $data = []) + { + $instance = \app\common\model\ApprovalInstances::where('biz_id', $data['id'])->where('flow_type', $data['flow_type'])->first(); + if (!$instance) { + return []; + } + $result = \app\common\model\ApprovalRecords::where('instance_id', $instance->id)->with('approver')->orderBy('id', 'desc')->get(); + return $result; + } +} \ No newline at end of file diff --git a/app/common/service/AuthService.php b/app/common/service/AuthService.php new file mode 100644 index 0000000..727ac41 --- /dev/null +++ b/app/common/service/AuthService.php @@ -0,0 +1,89 @@ +feishuAppInfo($app_id, $systemCcode); + $feishu = new Feishu($certificate['app_id'], $certificate['app_secret'], 'xinyouhe'); + return ['auth_url' => $feishu->authorize($certificate, $mobile)]; + } + + /** + * 登录,支持多类型 + */ + public function login(array $data): array + { + $loginType = $data['login_type'] ?? 'feishu'; + $logic = new AuthLogic(); + + return match ($loginType) { + 'feishu' => $logic->feishuLogin($data), + // 'qrcode' => $logic->qrCodeLogin($data), + default => $logic->uPwdLogin($data), + }; + } + + /** + * 用户注册 + */ + public function register(array $data) + { + $logic = new AuthLogic(); + try { + Db::beginTransaction(); + $result = $logic->register($data); + Db::commit(); + return $result; + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + } + + /** + * 校验token + */ + public function verifyToken(string $token, string $systemCode): array + { + return (new AuthLogic())->verifyToken($token, $systemCode); + } + + /** + * 刷新token + * @param string $refreshToken + * @param string|null $systemCode + */ + public function refreshToken(string $refreshToken, string $systemCode): array + { + return (new AuthLogic())->refreshToken($refreshToken, $systemCode); + } + + /** + * 登出(失效token) + */ + public function logout(?string $token, string $systemCode): void + { + (new AuthLogic())->logout($token, $systemCode); + } + + /** + * 强制拉黑token + */ + public function blockToken(string $token, string $systemCode): void + { + (new AuthLogic())->blockToken($token, $systemCode); + } +} \ No newline at end of file diff --git a/app/common/service/BasicDataService.php b/app/common/service/BasicDataService.php new file mode 100644 index 0000000..84dc232 --- /dev/null +++ b/app/common/service/BasicDataService.php @@ -0,0 +1,134 @@ +checkParamInCache(self::LOGIN_TYPE_KEY, $loginType)) { + $this->cacheLoginTypes(); + if (!$this->checkParamInCache(self::LOGIN_TYPE_KEY, $loginType)) { + throw new BusinessException('无效的登录方式'); + } + } + + return true; + } + + /** + * 检查参数是否在Redis集合中 + */ + private function checkParamInCache($cacheKey, $param) + { + return Redis::sIsMember($cacheKey, $param); + } + + /** + * 缓存所有登录类型到集合 + */ + private function cacheLoginTypes() + { + $cacheKey = self::LOGIN_TYPE_KEY; + + // 如果集合已存在,直接返回 + if (Redis::exists($cacheKey)) { + return; + } + + $loginTypes = $this->getAllLoginTypes(); + if (empty($loginTypes)) { + throw new BusinessException('未获取到登录类型数据'); + } + + // 批量添加值到集合 + Redis::sAdd($cacheKey, ...$loginTypes); + Redis::expire($cacheKey, 3600); // 1小时有效期 + } + + /** + * 缓存所有系统码到集合 + */ + private function cacheSystemCodes() + { + $cacheKey = self::SYSTEM_CODE_KEY; + + // 如果集合已存在,直接返回 + if (Redis::exists($cacheKey)) { + return; + } + + $systemCodes = $this->getAllSystemCodes(); + if (empty($systemCodes)) { + throw new BusinessException('未获取到系统码数据'); + } + + // 批量添加值到集合 + Redis::sAdd($cacheKey, ...$systemCodes); + Redis::expire($cacheKey, 3600); // 1小时有效期 + } + + /** + * 缓存所有公司标识到集合 + */ + private function cacheCorpAbbrs() + { + $cacheKey = self::CORP_ABBR_KEY; + + // 如果集合已存在,直接返回 + if (Redis::exists($cacheKey)) { + return; + } + + $corpAbbrs = $this->getAllCorpAbbrs(); + if (empty($corpAbbrs)) { + throw new BusinessException('未获取到公司标识数据'); + } + + // 批量添加值到集合 + Redis::sAdd($cacheKey, ...$corpAbbrs); + Redis::expire($cacheKey, 3600); // 1小时有效期 + } + + /** + * 获取所有有效的登录类型 + */ + private function getAllLoginTypes() + { + return ['upwd', 'feishu', 'qrcode']; // 示例数据 + } + + /** + * 获取所有有效的系统码 + */ + private function getAllSystemCodes() + { + return Systems::pluck('code')->toArray(); + } + + /** + * 获取所有有效的公司标识 + */ + private function getAllCorpAbbrs() + { + return Corp::pluck('abbr')->toArray(); + } +} \ No newline at end of file diff --git a/app/common/service/BrandSeriesService.php b/app/common/service/BrandSeriesService.php new file mode 100644 index 0000000..ae91723 --- /dev/null +++ b/app/common/service/BrandSeriesService.php @@ -0,0 +1,34 @@ +logic = (new BrandSeriesLogic()); + } + + /** + * 获取系统list + */ + public function getList($params) + { + if (empty($params['brand_id'])) + throw new BusinessException('品牌id不能为空'); + $where = []; + if(isset($params['brand_id']) && $params['brand_id']) + $where[] = ['brand_id', '=', $params['brand_id']]; + return $this->logic->getList($where); + } +} \ No newline at end of file diff --git a/app/common/service/BrandService.php b/app/common/service/BrandService.php new file mode 100644 index 0000000..c3430fe --- /dev/null +++ b/app/common/service/BrandService.php @@ -0,0 +1,24 @@ +logic = (new BrandLogic()); + } + + /** + * 获取系统list + */ + public function getList() + { + return $this->logic->getList(); + } +} \ No newline at end of file diff --git a/app/common/service/CorpFeishuAppService.php b/app/common/service/CorpFeishuAppService.php new file mode 100644 index 0000000..ad84582 --- /dev/null +++ b/app/common/service/CorpFeishuAppService.php @@ -0,0 +1,21 @@ +logic = (new CorpFeishuAppLogic()); + } + + public function getFeishuApp(int $corpId, int $systemId) + { + return $this->logic->getFeishuApp($corpId, $systemId); + } +} \ No newline at end of file diff --git a/app/common/service/CorpService.php b/app/common/service/CorpService.php new file mode 100644 index 0000000..7d18d49 --- /dev/null +++ b/app/common/service/CorpService.php @@ -0,0 +1,24 @@ +logic = (new CorpLogic()); + } + + /** + * 获取公司list + */ + public function getList() + { + return $this->logic->getList(); + } +} \ No newline at end of file diff --git a/app/common/service/CrawlService.php b/app/common/service/CrawlService.php new file mode 100644 index 0000000..f437ba6 --- /dev/null +++ b/app/common/service/CrawlService.php @@ -0,0 +1,90 @@ +find('div.card-name')->texts()->map(function($item) use(&$series) { + $series[] = $item; + }); + return $series; + } + + public function seriesModel($series) + { + $href = $this->modelSearch($series); + + //例如 $seriesUrl = "https://www.dongchedi.com/auto/series/4865?anchorId=carModels" + if (empty($href)) { + return []; + } + + $seriesUrl = (strpos($href, 'http') === 0) ? $href : ("https://www.dongchedi.com" . $href); + $ql = QueryList::get($seriesUrl); + + $nodes = $ql->find('div.tw-grid div.tw-relative div.tw-items-center>a'); + $texts = $nodes->texts()->all(); + $links = $nodes->attrs('href')->all(); + $nodes = $ql->find('div.tw-grid div.tw-text-color-gray-800'); + $price = $nodes->texts()->all(); + + $results = []; + foreach ($texts as $index => $text) { + $link = $links[$index] ?? ''; + if (!empty($link) && strpos($link, 'http') !== 0) { + $link = 'https://www.dongchedi.com' . $link; + } + $results[] = [ + 'title' => trim($text), + 'link' => $link, + 'msrp' => (int)(str_replace("万", "", $price[$index]) * 10000) ?? 0, + ]; + } + + return $results; + } + +// public function modelDesc(array $models) +// { +// +// } + + /** + * 返回车系地址 + * @param $series + * @return mixed|string + */ + public function modelSearch($series) + { + $url = "https://www.dongchedi.com/search?keyword=".$series; + $ql = QueryList::get($url ); + $href = ''; + $ql->find('a.fold-btn')->attrs('href') + ->map(function ($item) use(&$href) { + $href = $item; + }); + return $href; + } + + /** + * 关键词搜索的更多页面,通过面包屑抓取品牌与系列 + * @param $series + * @return mixed|string + */ + public function crawlBrandSeries($series) + { + $url = "https://www.dongchedi.com/auto/series/" . $series . "?anchorId=carModels"; + $ql = QueryList::get($url); + $result = $ql->find('div.breadcrumb_wrap__2GLeU span')->texts()->all(); + + return ['brand' => $result[2], 'series' => $result[3]]; + } +} \ No newline at end of file diff --git a/app/common/service/FinPaymentApplyService.php b/app/common/service/FinPaymentApplyService.php new file mode 100644 index 0000000..42cae9a --- /dev/null +++ b/app/common/service/FinPaymentApplyService.php @@ -0,0 +1,293 @@ +validator; + } + + /** + * 构建查询条件 + */ + protected function buildQueryConfig(Request $request, string $scene = 'index'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'index': + // ====== ====== 排序 ====== ====== + $config['order']['id'] = 'desc'; + + // ====== ====== 筛选 ====== ====== + // 汇款国家 + if (isset($p['country_id']) && !empty($p['country_id'])) { + $config['where']['country_id'] = $p['country_id']; + } + + // 收款账户筛选 + if (isset($p['account_id']) && !empty($p['account_id'])) { + $config['where']['account_id'] = $p['account_id']; + } + + // 入账资料超时 + if (isset($p['scene']) && $p['scene'] == 'file_timeout') { + $config['where']['claimed_at'] = ['between', [1, time()-24*60*60]]; + } + + // 关键字 + if (isset($p['keyword']) && $p['keyword']) { + $k = '%' . $p['keyword'] . '%'; + $config['orWhere'][] = ['remitter', 'like', $k]; + } + break; + case 'show': + $config['where']['id'] = $p['id']; + break; + case 'update': + case 'destroy': + $config['where']['id'] = $p['id']; + break; + case 'store': + break; + } + + return $config; + } + + public function beforeServiceStore($data, Request $request) + { + $data['apply_no'] = $this->generateUniqueApplyNo(); + $data['doc_user_id'] = $request->user->uid; + $data['voucher_file'] = json_encode($data['voucher_file']); + $data['submit_files'] = json_encode($data['submit_files']); + redis::set('stock_' . $data['doc_user_id'], json_encode($request->post('stock')), 'EX', 30); + unset($data['stock']); + if($data['voucher_file'] == '[]' || $data['voucher_file'] == '{}' || $data['voucher_file'] == '""') + $data['voucher_file'] = ''; + if($data['submit_files'] == '[]' || $data['submit_files'] == '{}' || $data['submit_files'] == '""') + $data['submit_files'] = ''; + return $data; + } + + + public function afterServiceStore($res) + { + $data = [ + 'payment_apply_id' => $res['id'], + ]; + $piStock = json_decode(redis::get('stock_' . $res['doc_user_id']), true); + redis::del('stock' . $res['doc_user_id']); + foreach ($piStock as $stock) { + foreach ($stock['stock_id'] as $v) { + $data['stock_id'] = $v; + $data['pi_no'] = $stock['pi_on']; + FinPaymentStock::create($data); + } + } + } + + public function beforeServiceUpdate($data, Request $request) + { + unset($data['stock']); + $data['submit_files'] = json_encode($data['submit_files']); + if($data['submit_files'] == '[]' || $data['submit_files'] == '{}' || $data['submit_files'] == '""') + $data['submit_files'] = ''; + $data['voucher_file'] = json_encode($data['voucher_file']); + if($data['voucher_file'] == '[]' || $data['voucher_file'] == '{}' || $data['voucher_file'] == '""') + $data['voucher_file'] = ''; + return $data; + } + + + public function afterServiceUpdate($res, Request $request) + { + if($res->submit_files != '' && $res->receipt_id != 0) + FinReceipt::where('id', $res->receipt_id)->update(['submit_files' => $res->submit_files]); + } + + /** + * 生成唯一的付款申请编码 + * 格式:PA + 日期 + 6位字母数字组合 + * + * @return string 唯一编码 + */ + public function generateUniqueApplyNo() + { + $chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $maxAttempts = 10; // 防止无限循环 + $secondChar = 'PA' . date('Ymd'); + + for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { + // 生成后6位随机字符 + $suffix = ''; + for ($i = 0; $i < 6; $i++) { + $suffix .= $chars[rand(0, strlen($chars) - 1)]; + } + + $applyNo = $secondChar . $suffix; + + // 检查是否已存在 + $existing = FinPaymentApply::where('apply_no', $applyNo)->first(); + if (!$existing) { + return $applyNo; + } + } + + // 如果1000次尝试后仍然重复,使用时间戳确保唯一性 + $timestamp = substr(time(), -6); + return $secondChar . $timestamp; + } + + /** + * 根据相似度排序付款记录 + * @param Request $request + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator|\Illuminate\Pagination\LengthAwarePaginator + */ + public function complexQueryIndex(Request $request) + { + $limit = (int)$request->get('size', 15); + $page = (int)$request->get('current', 1); + $param = $request->all(); + + $where = []; + $orWhere = []; + + // 汇款国家 + if (isset($param['country_id']) && !empty($param['country_id'])) { + $where[] = ['country_id', $param['country_id']]; + } + // 收款账户筛选 + if (isset($param['account_id']) && !empty($param['account_id'])) { + $where[] = ['account_id', $param['account_id']]; + } + + // 入账资料超时 + if (isset($param['scene']) && $param['scene'] == 'file_timeout') { + $where[] = ['submit_files', '']; + $where[] = ['claimed_at', '<', time()-24*60*60]; + $where[] = ['claimed_at', '>', 1]; + } + + // 关键字 + if (isset($param['keyword']) && $param['keyword']) { + $k = '%' . $param['keyword'] . '%'; + $where[] = ['remitter', 'like', $k]; + } + + // 构建查询 + $query = $this->modelClass::where($where); + + // 是否已关联收款记录的筛选 + $correlation = $param['correlation']??''; + if ($correlation === 'receipt') { //已关联 + $query->whereHas('receipt'); + } elseif ($correlation === 'unbind') { //未关联 + $query->whereDoesntHave('receipt'); + } + + if(isset($param['scene']) && $param['scene'] == 'pending'){ //待入账列表 + $query->where('receipt_id', '>' ,'0'); + $query->where('submit_files','!=',''); + $query->whereNull('received_at'); + } + + if($param['scene'] == 'delete'){ //查询作废数据 + $query->onlyTrashed(); + }elseif ($param['scene'] == 'all'){ //查询包含作废数据的全量数据 + $query->withTrashed(); + } + + if ((isset($param['scene']) && ($param['scene'] == 'list' || $param['scene'] == 'pending'))|| empty($param['receipt_id']) ) { + return $query->orderBy('id','desc')->paginate($limit, ['*'], 'page', $page); + } + + $receipt = FinReceipt::where('id', $param['receipt_id'])->first(); + $account_id = $receipt->account_id; + $currency_id = $receipt->currency_id; + $remitter = $receipt->remitter; + $amount = $receipt->amount; + + // 计算十天前的时间戳 + $sevenDaysAgo = time() - (10 * 24 * 60 * 60); + + // 构建相似度排序逻辑 + $similarityOrder = []; + + // 如果有传入参数,计算相似度 + if (!empty($account_id)) { + $similarityOrder[] = "CASE WHEN account_id = {$account_id} THEN 0 ELSE 1 END"; + } + + if (!empty($currency_id)) { + $similarityOrder[] = "CASE WHEN currency_id = {$currency_id} THEN 0 ELSE 1 END"; + } + + if (!empty($remitter)) { + $similarityOrder[] = "CASE WHEN remitter = '{$remitter}' THEN 0 ELSE 1 END"; + } + + if (!empty($amount)) { + $amount = (float)$amount; + $similarityOrder[] = "ABS(amount - {$amount})"; + $similarityOrder[] = "CASE WHEN (ABS(amount - {$amount}) / {$amount} * 100) <= 5 THEN 0 ELSE 1 END"; + } + + // 构建排序SQL + $orderByClause = ""; + if (!empty($similarityOrder)) { + $orderByClause = "CASE + WHEN created_at >= {$sevenDaysAgo} THEN + (" . implode(' + ', $similarityOrder) . ") + ELSE 0 + END ASC, "; + } + + $orderByClause .= "CASE + WHEN created_at > {$sevenDaysAgo} THEN id + ELSE 999999 + END DESC, "; + + $orderByClause .= "id DESC"; + + $query->orderByRaw($orderByClause); + + return $query->paginate($limit, ['*'], 'page', $page); + } + + public function delete($id) + { + $res = FinPaymentApply::where('id', $id)->delete(); + return $res; + } + + +} \ No newline at end of file diff --git a/app/common/service/FinReceiptHisService.php b/app/common/service/FinReceiptHisService.php new file mode 100644 index 0000000..f43f77b --- /dev/null +++ b/app/common/service/FinReceiptHisService.php @@ -0,0 +1,62 @@ +validator; + } + + /** + * 构建查询条件 + */ + protected function buildQueryConfig(Request $request, string $scene = 'index'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'index': + if(empty($p['receipt_id'])){ + throw new BusinessException('receipt_id不能为空'); + } + // ====== ====== 排序 ====== ====== + $config['order']['id'] = 'desc'; + + // ====== ====== 筛选 ====== ====== + $config['where']['receipt_id'] = $p['receipt_id']; + + break; + case 'show': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } +} \ No newline at end of file diff --git a/app/common/service/FinReceiptService.php b/app/common/service/FinReceiptService.php new file mode 100644 index 0000000..df236ea --- /dev/null +++ b/app/common/service/FinReceiptService.php @@ -0,0 +1,335 @@ +logic = (new FinReceiptLogic()); + } + + /** + * 获取验证器 + */ + public function getValidator() + { + return new $this->validator; + } + + + /** + * 构建查询条件 + */ + protected function buildQueryConfig(Request $request, string $scene = 'index'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'index': + // ====== ====== 排序 ====== ====== + $config['order']['id'] = 'desc'; + + // ====== ====== 筛选 ====== ====== + // 汇款国家 + if (isset($p['country_id']) && !empty($p['country_id'])) { + $config['where']['country_id'] = $p['country_id']; + } + + // 没有付款记录的收款记录 + if (isset($p['payment_apply']) && $p['payment_apply'] == 'not') { //未认领 + $config['where']['payment_apply_id'] = 0; + } elseif (isset($p['payment_apply']) && $p['payment_apply'] == 'already') { //已认领 + $config['where']['payment_apply_id'] = ['>', 0]; + } elseif (isset($p['payment_apply']) && $p['payment_apply'] == 'timeout') { //超时未认领 + $config['where']['payment_apply_id'] = 0; + $config['where']['created_at'] = ['<' ,time() - 24*60*60]; + } + + // 收款申请筛选 + if (isset($p['payment_apply_id']) && !empty($p['payment_apply_id'])) { + $config['where']['payment_apply_id'] = $p['payment_apply_id']; + } + + // 收款账户筛选 + if (isset($p['account_id']) && !empty($p['account_id'])) { + $config['where']['account_id'] = $p['account_id']; + } + + // 关键字 + if (isset($p['keyword']) && $p['keyword']) { + $k = '%' . $p['keyword'] . '%'; + $config['orWhere'][] = ['remitter', 'like', $k]; + } + break; + case 'show': + $config['where']['id'] = $p['id']; + break; + case 'update': + case 'payment': + case 'destroy': + $config['where']['id'] = $p['id']; + break; + case 'store': + break; + } + + return $config; + } + + public function beforeServiceStore($data, Request $request) + { + if(isset($data['payment_apply_id']) && empty($data['payment_apply_id'])){ + unset($data['payment_apply_id']); + } + $data['receipt_no'] = $this->generateUniqueReceipt(); + $data['arrived_at'] = strtotime( $data['arrived_at']); + $data['created_by'] = $request->user->uid; + $data['updated_by'] = $request->user->uid; + $data['fx_receipt_file'] = json_encode($data['fx_receipt_file']); + if($data['fx_receipt_file'] == '[]' || $data['fx_receipt_file'] == '{}' || $data['fx_receipt_file'] == '""') + $data['fx_receipt_file'] = ''; + return $data; + } + + public function afterServiceStore($res) + { + $receipt = FinReceipt::where('id',$res->id)->first()->toArray(); + $receipt = array_merge($receipt,['receipt_id' => $res->id]); + unset($receipt['id']); + FinReceiptHis::create($receipt); + } + + public function beforeServiceUpdate($data, Request $request) + { + $data['updated_by'] = $request->user->uid; + if(isset($data['arrived_at'])) + $data['arrived_at'] = strtotime($data['arrived_at']); + if(isset($data['received_at'])) + $data['received_at'] = strtotime($data['received_at']); + + $data['fx_receipt_file'] = json_encode($data['fx_receipt_file']); + if($data['fx_receipt_file'] == '[]' || $data['fx_receipt_file'] == '{}' || $data['fx_receipt_file'] == '""') + $data['fx_receipt_file'] = ''; + return $data; + } + public function afterServiceUpdate($res) + { + $receipt = FinReceipt::where('id',$res['id'])->first()->toArray(); + $receipt = array_merge($receipt,['receipt_id' => $res['id']]); + unset($receipt['id']); + FinReceiptHis::create($receipt); + } + + + /** + * 生成唯一的付款申请编码 + * 格式:PA + 日期 + 6位字母数字组合 + * + * @return string 唯一编码 + */ + public function generateUniqueReceipt() + { + $chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $maxAttempts = 10; // 防止无限循环 + $secondChar = 'RE' . date('Ymd'); + + for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { + // 生成后6位随机字符 + $suffix = ''; + for ($i = 0; $i < 6; $i++) { + $suffix .= $chars[rand(0, strlen($chars) - 1)]; + } + + $ReceiptNo = $secondChar . $suffix; + + // 检查是否已存在 + $existing = FinReceipt::where('receipt_no', $ReceiptNo)->first(); + if (!$existing) { + return $ReceiptNo; + } + } + + // 如果1000次尝试后仍然重复,使用时间戳确保唯一性 + $timestamp = substr(time(), -6); + return $secondChar . $timestamp; + } + + /** + * 根据相似度排序付款记录 + * @param Request $request + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator|\Illuminate\Pagination\LengthAwarePaginator + */ + public function complexQueryIndex(Request $request) + { + $limit = (int)$request->get('size', 15); + $page = (int)$request->get('current', 1); + $param = $request->all(); + + if(isset($param['voucher_no_timeout_hour'])){ + redis::set('voucher_no_timeout_hour', $param['voucher_no_timeout_hour'], 'EX', 30); + } + + $where = []; + + // 汇款国家 + if (isset($param['country_id']) && !empty($param['country_id'])) { + $where[] = ['country_id', $param['country_id']]; + } + // 收款账户筛选 + if (isset($param['account_id']) && !empty($param['account_id'])) { + $where[] = ['account_id', $param['account_id']]; + } + // 收款申请筛选 + if (isset($param['payment_apply_id']) && !empty($param['payment_apply_id'])) { + $where[]['payment_apply_id'] = $param['payment_apply_id']; + } + + // 关键字 + if (isset($param['keyword']) && $param['keyword']) { + $k = '%' . $param['keyword'] . '%'; + $where[] = ['remitter', 'like', $k]; + } + + // 构建查询 + $query = $this->modelClass::where($where); + + // 收款记录绑定情况 + if (isset($param['payment_apply']) && $param['payment_apply'] == 'not') { + $query->where('payment_apply_id', 0); + } elseif (isset($param['payment_apply']) && $param['payment_apply'] == 'already') { + $query->where('payment_apply_id', '>', 0); + } elseif (isset($param['payment_apply']) && $param['payment_apply'] == 'timeout') { //超时未认领 + $query->where('payment_apply_id', 0); + $query->where('created_at', '<' ,time() - 24*60*60); + } + + if(isset($param['scene']) && $param['scene'] == 'pending'){ //待入账列表 + $query->where('payment_apply_id', '>' ,'0'); + $query->where('submit_files','!=',''); + } + + if (isset($param['scene']) && ($param['scene'] == 'list' ||$param['scene'] == 'pending')) { + return $query->paginate($limit, ['*'], 'page', $page); + } + + // 计算七天前的时间戳 + $sevenDaysAgo = time() - (10 * 24 * 60 * 60); + + // 构建相似度排序逻辑 + $similarityOrder = []; + + // 如果有传入参数,计算相似度 + if (isset($param['account_id']) && !empty($param['account_id'])) { + $similarityOrder[] = "CASE WHEN account_id = {$param['account_id']} THEN 0 ELSE 1 END"; + } + + if (isset($param['country_id']) && !empty($param['country_id'])) { + $similarityOrder[] = "CASE WHEN country_id = {$param['country_id']} THEN 0 ELSE 1 END"; + } + + if (isset($param['keyword']) && $param['keyword']) { + $keyword = addslashes($param['keyword']); + $similarityOrder[] = "CASE WHEN remitter LIKE '%{$keyword}%' THEN 0 ELSE 1 END"; + } + + if (isset($param['amount']) && !empty($param['amount'])) { + $amount = (float)$param['amount']; + $similarityOrder[] = "ABS(amount - {$amount})"; + } + + // 构建排序SQL + $orderByClause = ""; + if (!empty($similarityOrder)) { + $orderByClause = "CASE + WHEN created_at >= {$sevenDaysAgo} THEN + (" . implode(' + ', $similarityOrder) . ") + ELSE 999999 + END ASC, "; + } + + $orderByClause .= "CASE + WHEN created_at < {$sevenDaysAgo} THEN id + ELSE 0 + END DESC"; + + $query->orderByRaw($orderByClause); + + return $query->paginate($limit, ['*'], 'page', $page); + } + + public function credit($id, $exchange_rate) + { + $finReceipt = FinReceipt::find($id); + if(!empty($finReceipt->received_at)){ + throw new BusinessException('该记录已将确认入账'); + } + + $data = [ + 'received_at' => time(), + 'exchange_rate' => $exchange_rate, + 'total_amount_cny' => round($finReceipt['amount']*$exchange_rate,2), + ]; + $res = $this->logic->credit($id,$data); + FinPaymentApply::where('id', $finReceipt->payment_apply_id)->update(['received_at' => $data['received_at']]); + $this->afterServiceUpdate(['id'=>$id]); + return $res; + } + + /** + * 更新:绑定收款记录,计算折让金额 + * @param Request $request + * @param array $data + * @param string $scene + * @return mixed + */ + public function serviceUpdatePayment(Request $request, array $data = [], string $scene = 'payment') + { + $payment_apply_id = $request->input('payment_apply_id'); + + $id = $request->input('id'); + $data = $this->preProcess(ucfirst($scene), $data, $request); + $config = $this->buildQueryConfig($request, $scene); + FinPaymentApply::where('id', $payment_apply_id)->update(['receipt_id' => $id, 'claimed_at' => time(), 'claimed_by' => $request->user->uid]); + FinPaymentStock::where('payment_apply_id', $payment_apply_id)->update(['receipt_id' => $id]); + + //折让金额 + $payment = FinPaymentApply::where('id', $payment_apply_id)->first(); + $receipt = FinReceipt::find($id); + $data['discount_amount'] = $payment->amount - $receipt->amount ?? 0; + $data['submit_files'] = $payment->submit_files ?? ''; + + $updated = $this->logicUpdateBy($config, $data); + return $this->postProcess(ucfirst($scene), $data); + } + +} \ No newline at end of file diff --git a/app/common/service/FinanceReconDubaiFilesService.php b/app/common/service/FinanceReconDubaiFilesService.php new file mode 100644 index 0000000..33f9b64 --- /dev/null +++ b/app/common/service/FinanceReconDubaiFilesService.php @@ -0,0 +1,290 @@ +generatePrivateUrl() 完成 + */ +class FinanceReconDubaiFilesService +{ + use CrudServiceTrait; + use ExcelImportTrait; // 复用通用导入方法 + + /** 指定当前要操作的模型(用于 CrudServiceTrait) */ + protected string $modelClass = FinanceReconDubaiFiles::class; + + /** 验证器 */ + protected $validator = FinanceReconDubaiFilesValidate::class; + + /** 获取验证器实例 */ + public function getValidator() + { + return new $this->validator; + } + + /** + * 写入前置:将上传组件的结构转为 *_url / *_name,并记录创建人 + */ + public function beforeServiceStore($data, Request $request) + { + $data['created_by'] = $request->user->uid ?? 0; + $data['caiwu_file_url'] = $data['caiwu_file']['url'] ?? ''; + $data['caiwu_file_name'] = $data['caiwu_file']['name'] ?? ''; + $data['dubai_file_url'] = $data['dubai_file']['url'] ?? ''; + $data['dubai_file_name'] = $data['dubai_file']['name'] ?? ''; + return $data; + } + + /* ============================================================ + * 一、M项目对账明细 导入配置(sheet: M项目对账明细) + * ============================================================ */ + + /** 中文表头 => 目标字段名 */ + protected array $headerMapM = [ + '车架号' => 'vin', + '开票价' => 'invoice_price', + '实际采购价' => 'actual_purchase_price', + '开票价和实际采购价差额' => 'invoice_vs_purchase_diff', + '车源中介费' => 'source_broker_fee', + '配件' => 'parts_fee', + '停车费' => 'parking_fee', + '保险费' => 'insurance_fee', + '车辆手续费' => 'vehicle_service_fee', + '检测费' => 'inspection_fee', + '购置税' => 'purchase_tax', + '汽车其他费用' => 'other_vehicle_fee', + '转移待出口' => 'transfer_to_export', + '国际运费' => 'freight_international', + '国内运费1' => 'freight_domestic_1', + '国内运费2' => 'freight_domestic_2', + '报关费及港杂费' => 'customs_port_fee', + '注销费' => 'cancellation_fee', + '车船税' => 'vehicle_vessel_tax', + '其他财务费用' => 'other_finance_fee', + '汽车小额杂费' => 'minor_vehicle_misc', + '上牌费' => 'registration_fee', + '其他保险费' => 'other_insurance_fee', + '油补' => 'fuel_subsidy', + '快递费' => 'express_fee', + '国内成本合计' => 'domestic_cost_total', + '退税额' => 'tax_refund_amount', + 'cif杰贝阿里成本价' => 'cif_jebel_ali_cost', + '范经理发车看板CIF' => 'fan_manager_cif', + '财务-范经理 差额' => 'finance_vs_fan_diff', + ]; + + /** 需要转为 decimal 的目标字段 */ + protected array $decimalFieldsM = [ + 'invoice_price','actual_purchase_price','invoice_vs_purchase_diff', + 'source_broker_fee','parts_fee','parking_fee','insurance_fee','vehicle_service_fee','inspection_fee', + 'purchase_tax','other_vehicle_fee','transfer_to_export', + 'freight_international','freight_domestic_1','freight_domestic_2','customs_port_fee', + 'cancellation_fee','vehicle_vessel_tax','other_finance_fee','minor_vehicle_misc', + 'registration_fee','other_insurance_fee','fuel_subsidy','express_fee', + 'domestic_cost_total','tax_refund_amount','cif_jebel_ali_cost','fan_manager_cif','finance_vs_fan_diff', + ]; + + /** + * M项目对账明细导入 + * + * @param string $file 可为“签名 URL”或“OSS 对象键” + * @param bool $isUrl 若传入的是已签名 URL,请设 true;若是对象键,设 false + * @param bool $insertToDb 是否立即入库 + * + * 返回:['sheet','total_rows','skipped','errors','to_import','inserted','preview_3',('error'?)] + */ + public function mImport(string $file, bool $isUrl = true, bool $insertToDb = true): array + { + $sheet = 'M项目对账明细'; + + // 允许传对象键:当 isUrl=false 且非 http(s) 时,自动用 OssService 签名 + if ($isUrl === false && !preg_match('#^https?://#i', $file)) { + $file = $this->ossSignedUrl($file); + $isUrl = true; + } + + $rows = $this->readSheetRows($file, $sheet, $isUrl, headerRow: 1); + $mapped = []; + $skipped = 0; + $errors = 0; + + foreach ($rows as $row) { + try { + if ($this->shouldSkipByVinAliases($row, ['车架号'])) { $skipped++; continue; } + $rec = $this->mapAndNormalizeRow($row, $this->headerMapM, $this->decimalFieldsM); + $this->stamp($rec); + $mapped[] = $rec; + } catch (\Throwable $e) { + // 单行异常不阻断全局导入 + $errors++; continue; + } + } + + $result = [ + 'sheet' => $sheet, + 'total_rows' => count($rows), + 'skipped' => $skipped, + 'errors' => $errors, + 'to_import' => count($mapped), + ]; + + if ($insertToDb) { + // 注意表名:按你的业务这里写入 finance_recon_m_detail + $ret = $this->batchInsert('finance_recon_m_detail', $mapped); + $result['inserted'] = $ret['inserted']; + if ($ret['error']) $result['error'] = 'Database insert error: ' . $ret['error']; + } else { + $result['inserted'] = 0; + } + + $result['preview_3'] = array_slice($mapped, 0, 3); + return $result; + } + + /** M项目对账明细:从 OSS 对象键导入(语义化便捷入口) */ + public function mImportFromOssKey(string $objectKey, bool $insertToDb = true): array + { + $signedUrl = $this->ossSignedUrl($objectKey); + return $this->mImport($signedUrl, true, $insertToDb); + } + + /** M项目对账明细:从已签名 URL 导入(语义化便捷入口) */ + public function mImportFromSignedUrl(string $signedUrl, bool $insertToDb = true): array + { + return $this->mImport($signedUrl, true, $insertToDb); + } + + /* ============================================================ + * 二、迪拜两表合并 导入配置(sheets: Stock in Jebel Ali / Under shipping) + * ============================================================ */ + + /** 表头映射(已兼容不一致列名:VIN NO/车架号、国际运费和杂费/SHIPPING COST) */ + protected array $headerMapDubai = [ + 'VIN NO' => 'vin', '车架号' => 'vin', + '开票价' => 'invoice_price', + '中介费' => 'broker_fee', + '购置税' => 'purchase_tax', + '退税' => 'tax_refund', + '不含税车价' => 'price_ex_vat', + '费用' => 'fee_total', + '国内运费' => 'domestic_freight', + 'FCA成本价' => 'fca_cost', + 'FCA成本价汇率' => 'fca_fx', + '国际运费和杂费' => 'shipping_cost', + 'SHIPPING COST' => 'shipping_cost', + '运费汇率' => 'shipping_fx', + 'CIF 杰贝阿里成本价' => 'cif_jebel_ali_cost', + 'CIF RMB' => 'cif_rmb', // Under shipping 没有该列,将在逻辑中推导 + ]; + + /** 需要转为 decimal 的目标字段(汇率字段使用 DECIMAL(18,6) 建表) */ + protected array $decimalFieldsDubai = [ + 'invoice_price','broker_fee','purchase_tax','tax_refund','price_ex_vat', + 'fee_total','domestic_freight','fca_cost','fca_fx', + 'shipping_cost','shipping_fx','cif_jebel_ali_cost','cif_rmb', + ]; + + /** + * 迪拜两表合并导入(Stock in Jebel Ali / Under shipping) + * + * 缺失值规则: + * - Under shipping 无 “CIF RMB”,尝试:cif_rmb = cif_jebel_ali_cost × shipping_fx + * 若运费汇率缺失则用 fca_fx 兜底,都没有则置 NULL。 + * + * 返回:['sheets','total_rows','skipped','to_import','inserted','preview_5',('error'?)] + */ + public function dImport(string $file, bool $isUrl = false, bool $insertToDb = true): array + { + // 允许传对象键:当 isUrl=false 且非 http(s) 时,自动用 OssService 签名 + if ($isUrl === false && !preg_match('#^https?://#i', $file)) { + $file = $this->ossSignedUrl($file); + $isUrl = true; + } + + $sheets = ['Stock in Jebel Ali', 'Under shipping']; + $parsed = []; + + // 逐 Sheet 读取 + foreach ($sheets as $sheet) { + $parsed[$sheet] = $this->readSheetRows($file, $sheet, $isUrl, headerRow: 1); + } + + // 行处理与合并 + $rowsAll = []; + $skipped = 0; + + foreach ($sheets as $sheet) { + foreach ($parsed[$sheet] as $row) { + if ($this->shouldSkipByVinAliases($row, ['VIN NO','车架号'])) { $skipped++; continue; } + + // 映射 + 数值规范化 + $rec = $this->mapAndNormalizeRow($row, $this->headerMapDubai, $this->decimalFieldsDubai); + + // Under shipping 无 CIF RMB:尝试用汇率推导 + if (empty($rec['cif_rmb'])) { + $cif = $rec['cif_jebel_ali_cost'] ?? null; + $fx = $rec['shipping_fx'] ?? null; + if ($cif !== null && $fx !== null) { + $rec['cif_rmb'] = round($cif * $fx, 2); + } elseif ($cif !== null && !empty($rec['fca_fx'])) { + $rec['cif_rmb'] = round($cif * (float)$rec['fca_fx'], 2); + } else { + $rec['cif_rmb'] = null; + } + } + + // 统一 VIN 与来源 + $rec['vin'] = $this->pickFirstNonEmpty($row, ['VIN NO','车架号']); + $rec['source_sheet'] = $sheet; + + $this->stamp($rec); + $rowsAll[] = $rec; + } + } + + // 结果与入库 + $result = [ + 'sheets' => $sheets, + 'total_rows' => array_sum(array_map('count', $parsed)), + 'skipped' => $skipped, + 'to_import' => count($rowsAll), + ]; + + if ($insertToDb) { + // 注意表名:按你的业务这里写入 finance_recon_d_detail + $ret = $this->batchInsert('finance_recon_d_detail', $rowsAll); + $result['inserted'] = $ret['inserted']; + if ($ret['error']) $result['error'] = 'Database insert error: ' . $ret['error']; + } else { + $result['inserted'] = 0; + } + + $result['preview_5'] = array_slice($rowsAll, 0, 5); + return $result; + } + + /** 迪拜两表:从 OSS 对象键导入(语义化便捷入口) */ + public function dImportFromOssKey(string $objectKey, bool $insertToDb = true): array + { + $signedUrl = $this->ossSignedUrl($objectKey); + return $this->dImport($signedUrl, true, $insertToDb); + } + + /** 迪拜两表:从已签名 URL 导入(语义化便捷入口) */ + public function dImportFromSignedUrl(string $signedUrl, bool $insertToDb = true): array + { + return $this->dImport($signedUrl, true, $insertToDb); + } +} \ No newline at end of file diff --git a/app/common/service/InquiryTransmitConfigService.php b/app/common/service/InquiryTransmitConfigService.php new file mode 100644 index 0000000..930124a --- /dev/null +++ b/app/common/service/InquiryTransmitConfigService.php @@ -0,0 +1,67 @@ +validator; + } + + /** + * 构造 CrudUtil 查询配置 + */ + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'default': + $config['where']['status'] = 1; + $config['order']['id'] = 'desc'; + break; + case 'index': + $config['where']['status'] = 1; + $config['order']['id'] = 'desc'; + break; + case 'show': + $config['where']['id'] = $p['id']; + $config['where']['status'] = 1; + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } +} \ No newline at end of file diff --git a/app/common/service/InquiryTransmitService.php b/app/common/service/InquiryTransmitService.php new file mode 100644 index 0000000..7b0fed4 --- /dev/null +++ b/app/common/service/InquiryTransmitService.php @@ -0,0 +1,92 @@ +validator; + } + + /** 创建 */ + public function serviceStore(Request $request, array $data = [], string $domain = '') + { + $data['email'] = $data['Email']; + $data['name'] = $data['Name']; + $data['tel'] = $data['Tel']; + $data['company'] = $data['Company']; + $data['country'] = $data['Country']; + $data['form_Url'] = $data['Form Url']; + $data['message'] = $data['Message']; + $data['ip_location'] = $data['IpLocation']; + $data['ip'] = $data['Ip']; + $data['domain'] = $domain; + + return InquiryTransmit::create($data); + } + + /** + * 构造 CrudUtil 查询配置 + */ + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'default': + $config['order']['id'] = 'desc'; + break; + case 'index': + // 域名 + if (isset($p['domain']) && !empty($p['domain'])) { + $config['where']['domain'] = $p['domain']; + } + // 关键字 + if (isset($p['keyword']) && $p['keyword']) { + $k = '%' . $p['keyword'] . '%'; + foreach (['email', 'name', 'tel', 'country', 'message', 'form_url'] as $col) { + $config['orWhere'][] = [$col, 'like', $k]; + } + } + $config['order']['id'] = 'desc'; + break; + case 'show': + $config['where']['id'] = $p['id']; + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } +} \ No newline at end of file diff --git a/app/common/service/InvItemService.php b/app/common/service/InvItemService.php new file mode 100644 index 0000000..5a4b8f1 --- /dev/null +++ b/app/common/service/InvItemService.php @@ -0,0 +1,199 @@ +logic = (new InvItemLogic()); + } + /** + * 获取验证器 + */ + public function getValidator() + { + return new $this->validator; + } + + /** + * 构造 CrudUtil 查询配置 + */ + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'default': + $config['where']['status'] = 1; + $config['order']['sort'] = 'desc'; + $config['order']['id'] = 'desc'; + break; + case 'index': + $config['where']['status'] = 1; + + // 关键字 + if (isset($p['keyword']) && $p['keyword']) { + $k = '%' . $p['keyword'] . '%'; + foreach (['item_name'] as $col) { + $config['orWhere'][] = [$col, 'like', $k]; + } + } + + $config['order']['sort'] = 'desc'; + $config['order']['id'] = 'desc'; + break; + case 'show': + $config['where']['id'] = $p['id']; + $config['where']['status'] = 1; + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } + + /** + * 生成 8-12 位的物品编码(默认10位) + * + * @param int $length 编码长度,默认为10(范围 8-12) + * @return string 返回大写的字母数字组合编码 + */ + private function generateItemCode($length = 10) + { + // 排除易混淆字符:0, O, o, 1, I, l + $chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; + $code = ''; + $maxIndex = strlen($chars) - 1; + + for ($i = 0; $i < $length; $i++) { + $code .= $chars[mt_rand(0, $maxIndex)]; + } + + return $code; + } + + + public function beforeServiceStore($data, Request $request): array + { + $data['item_code'] = $this->generateItemCode(); + $data['category_id'] = 0; + $data['spec_model'] = ''; + $data['unit'] = '件'; + $data['is_consumable'] = 0; + $data['stock_qty'] = $data['stock_qty']??1; + $data['location'] = ''; + return $data; + } + + + /** + * 变动物品库存数量 + * + * @param int $itemId 物品ID + * @param int $changeQty 变动数量:>0 入库,<0 出库 + * @param bool $allowNegative 是否允许库存变成负数,默认不允许 + * + * @return InvItem 最新的物品记录 + * @throws BusinessException + */ + public function changeStock(int $itemId, int $changeQty, bool $allowNegative = false): InvItem + { + if ($changeQty === 0) { + throw new BusinessException('库存变动数量不能为 0'); + } + + $now = time(); + + // 构建查询 + $query = InvItem::query()->where('id', $itemId); + + // 如果不允许负数,并且是出库场景(变动为负) + if (! $allowNegative && $changeQty < 0) { + // 要保证:当前 stock_qty >= 出库数量 + $query->where('stock_qty', '>=', -$changeQty); + } + + // 使用 increment 实现:UPDATE inv_item SET stock_qty = stock_qty + ?, updated_at = ? WHERE ... + $affected = $query->increment('stock_qty', $changeQty, [ + 'updated_at' => $now, + ]); + + if ($affected === 0) { + // 更新不到行: + // 1) 物品不存在 + // 2) 或者库存不足(不允许负数时) + $exists = InvItem::query()->where('id', $itemId)->exists(); + if (! $exists) { + throw new BusinessException('物品不存在'); + } + throw new BusinessException('库存不足,无法完成库存变动'); + } + + // 再查一次最新数据返回(在外层事务中也会保持一致) + /** @var InvItem $item */ + $item = InvItem::query()->find($itemId); + return $item; + } + + /** + * 出库(减库存,默认不允许负数) + */ + public function stockOut(int $itemId, int $qty, bool $allowNegative = false): InvItem + { + if ($qty <= 0) { + throw new BusinessException('出库数量必须大于 0'); + } + return $this->changeStock($itemId, -$qty, $allowNegative); + } + + /** + * 入库(加库存) + */ + public function stockIn(int $itemId, int $qty): InvItem + { + if ($qty <= 0) { + throw new BusinessException('入库数量必须大于 0'); + } + return $this->changeStock($itemId, $qty, true); + } + + /** + * 获取车型list + */ + public function getList() + { + return $this->logic->getList(); + } +} \ No newline at end of file diff --git a/app/common/service/InvRequisitionApproveService.php b/app/common/service/InvRequisitionApproveService.php new file mode 100644 index 0000000..07571a0 --- /dev/null +++ b/app/common/service/InvRequisitionApproveService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/InvRequisitionAttachmentService.php b/app/common/service/InvRequisitionAttachmentService.php new file mode 100644 index 0000000..aeec408 --- /dev/null +++ b/app/common/service/InvRequisitionAttachmentService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/InvRequisitionItemReturnAttachmentService.php b/app/common/service/InvRequisitionItemReturnAttachmentService.php new file mode 100644 index 0000000..fd70cd1 --- /dev/null +++ b/app/common/service/InvRequisitionItemReturnAttachmentService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/InvRequisitionItemReturnService.php b/app/common/service/InvRequisitionItemReturnService.php new file mode 100644 index 0000000..a90f199 --- /dev/null +++ b/app/common/service/InvRequisitionItemReturnService.php @@ -0,0 +1,449 @@ +validator; + } + + /** + * 将二维数组按指定 key 重组为关联数组,该 key 的值作为新数组的键, + * 其余元素组成子数组作为值。 + * + * @param array $array 原始二维数组 + * @param string $keyField 用作新键的字段名(如 'id') + * @return array 重组后的数组 + */ + private function groupArrayByKey($array, $keyField = 'id') + { + $result = []; + foreach ($array as $item) { + if (!isset($item[$keyField])) { + continue; // 跳过没有该 key 的项 + } + $key = $item[$keyField]; + // 移除用作键的字段 + unset($item[$keyField]); + $result[$key] = $item; + } + return $result; + } + + + /** + * 创建物品归还申请前置处理 + * + * @param array $data 归还申请数据 + * @param Request $request 请求对象 + * @return array 处理后的数据 + */ + public function beforeServiceStore($data, Request $request): array + { + try { + // // 模拟数据 + // $data = [ + // [ + // 'requisition_item_id' => 9, + // 'return_qty' => 1, + // 'apply_remark' => '申请备注', + // 'attachment' => [ + // [ + // 'original_name' => '附件1.jpg', + // 'file_path' => '/uploads/2023/07/01/attachment1.jpg' + // ], + // [ + // 'original_name' => '附件2.jpg', + // 'file_path' => '/uploads/2023/07/01/attachment2.jpg' + // ] + // ] + // ] + // ]; + + // 获取所有申请项ID + $reqItemIds = array_column($data, 'requisition_item_id'); + + // 参数校验 + if (empty($reqItemIds)) { + throw new BusinessException('未提供有效的申请项ID'); + } + + // 将申请项按 ID 重组为关联数组 + $newReqItems = $this->groupArrayByKey($data, 'requisition_item_id'); + + // 获取申请项明细 + $reqItemsArr = InvRequisitionItem::whereIn('id', $reqItemIds)->get()->toArray(); + + // 检查是否获取到有效数据 + if (empty($reqItemsArr)) { + throw new BusinessException('未找到对应的申请项'); + } + + // 获取用户信息 + $userService = new UserService(); + $user = $userService->getUserInfo($request->user->uid, ''); + + $arr = []; + foreach ($reqItemsArr as $reqItem) { + $reqItemId = $reqItem['id']; + + // 检查是否有对应的新申请项数据 + if (!isset($newReqItems[$reqItemId])) { + continue; + } + + $arr[] = [ + 'requisition_id' => $reqItem['requisition_id'], + 'requisition_item_id' => $reqItemId, + 'item_id' => $reqItem['item_id'], + 'apply_user_id' => $user['user']['id'], + 'apply_user_name' => $user['user']['name'], + 'return_qty' => $newReqItems[$reqItemId]['return_qty'], + 'apply_remark' => $newReqItems[$reqItemId]['apply_remark'] ?? '', + 'attachment' => $newReqItems[$reqItemId]['attachment'], + 'return_at' => time() + ]; + } + + return $arr; + } catch (BusinessException $e) { + throw $e; + } catch (\Throwable $th) { + throw new BusinessException('创建物品归还申请失败:' . $th->getMessage()); + } + } + + /** + * 处理物品申请 + * + * @param Request $request + * @param array $data + * @return array + */ + public function serviceStore(Request $request, array $data, string $scene = '') + { + try { + // 关联表增加事务处理 + return Db::transaction(function () use ($request, $data) { + $data = $this->preProcess('Store', $data, $request); + $dl = $this->getDataLimitFromRequest($request); + foreach ($data as $item) { + $created = $this->logicCreate($item, $dl); + $this->processRequisitionItemReturnAttachment($created, $item['attachment']); + } + return $this->postProcess('Store', $created); + }); + } catch (\Throwable $e) { + throw new BusinessException("创建物品申请失败:" . $e->getMessage()); + } + } + + + /** + * 更新物品归还申请前置处理 + * + * @param array $data 归还申请数据 + * @param Request $request 请求对象 + * @return array 处理后的数据 + */ + public function beforeServiceUpdate($data, Request $request): array + { + // // 模拟数据 + // $data = [ + // 'id' => 1, + // 'requisition_item_id' => 9, + // 'return_qty' => 1, + // 'apply_remark' => '申请备注22', + // 'attachment' => [ + // [ + // 'original_name' => '附件1.jpg', + // 'file_path' => '/uploads/2023/07/01/attachment1.jpg' + // ], + // [ + // 'original_name' => '附件2.jpg', + // 'file_path' => '/uploads/2023/07/01/attachment2.jpg' + // ], + // [ + // 'original_name' => '附件3.jpg', + // 'file_path' => '/uploads/2023/07/01/attachment3.jpg' + // ] + // ] + // ]; + + $data['return_at'] = time(); + return $data; + } + + /** + * 更新物品归还申请 + * + * @param Request $request + * @param array $data + * @return array + */ + public function serviceUpdate(Request $request, array $data, string $scene = '') + { + try { + // 关联表增加事务处理 + return Db::transaction(function () use ($request, $data) { + $data = $this->preProcess('Update', $data, $request); + $config = $this->buildQueryConfig($request, 'update'); + + $attachment = $data['attachment']; + unset($data['attachment']); + + $this->logicUpdateBy($config, $data); + // 获取更新后的完整记录 + $updated = $this->logicShow($config); + // 处理附件更新 + $this->processRequisitionItemReturnAttachment($updated, $attachment, true); + return $this->postProcess('Update', $updated); + }); + } catch (\Throwable $e) { + throw new BusinessException("更新物品归还申请失败:" . $e->getMessage()); + } + } + + /** + * 处理物品申请附件 + * + * @param Model $created + * @param array $items + * @param bool $isUpdate + * @return void + */ + private function processRequisitionItemReturnAttachment(Model $created, array $attachment, bool $isUpdate = false): void + { + if ($isUpdate) { + $this->clearRequisitionItemReturnAttachment($created->id); + } + + foreach ($attachment as $file) { + $attachmentData = [ + 'return_id' => $created->id, + 'file_name' => $file['original_name'], + 'file_path' => $file['file_path'] + ]; + InvRequisitionItemReturnAttachment::create($attachmentData); + } + } + + /** + * 清空物品申请附件 + * + * @param int $returnId + * @return void + */ + private function clearRequisitionItemReturnAttachment(int $returnId): void + { + InvRequisitionItemReturnAttachment::where('return_id', $returnId)->delete(); + } + + /** + * 物品领用归还确认 + * + * @param Request $request + * @param array $data + * @param string $scene + * @return array|bool + * @throws BusinessException + */ + public function serviceReturnConfirm(Request $request, array $data, string $scene = 'returnConfirm') + { + try { + // 归还确认是业务顶层方法,这里统一使用事务 + return Db::transaction(function () use ($request, $data, $scene) { + + // 1. 当前操作人信息 + $userInfo = (new UserService())->getUserInfo($request->user->uid, ''); + if (empty($userInfo['user']['id']) || empty($userInfo['user']['name'])) { + throw new BusinessException('无法获取当前用户信息'); + } + + $handlerId = (int)$userInfo['user']['id']; + $handlerName = (string)$userInfo['user']['name']; + + $data['handler_id'] = $handlerId; + $data['handler_name'] = $handlerName; + + // 确认状态:1=通过,2=拒绝 (按你现在用法) + $status = isset($data['status']) ? (int)$data['status'] : null; + if (!in_array($status, [1, 2], true)) { + throw new BusinessException('非法的归还处理状态'); + } + + // 2. 构建查询配置,定位到这条归还记录 + $config = $this->buildQueryConfig($request, $scene); + + // 更新时不允许前端覆盖主键 + unset($data['requisition_item_return_id']); + + // 3. 获取现有记录 + /** @var \app\model\InvRequisitionItemReturn|null $existingRecord */ + $existingRecord = $this->logicShow($config); + + if (!$existingRecord) { + throw new BusinessException('未找到对应的归还申请记录'); + } + + // 已处理校验:只要归还记录的 status 已经是「通过或拒绝」,就不能再处理 + $existingStatus = (int)($existingRecord->status ?? 0); + if (in_array($existingStatus, [1, 2], true) || !empty($existingRecord->handler_id)) { + throw new BusinessException('该归还申请已经被处理过了'); + } + + // 4. 更新归还记录(状态 + 审核人 + 备注等) + $updateResult = $this->logicUpdateBy($config, $data); + if ($updateResult === 0) { + throw new BusinessException('更新归还申请失败,可能记录不存在或已被处理'); + } + + // 5. 若是拒绝,直接返回(不改库存、不改已归还数量) + if ($status === 2) { + // 拒绝:这里你返回 true 或者返回更新后的记录都可以 + return true; + } + + // 6. 审核通过:继续处理领用明细 & 库存 & 领用单状态 + // 注意:requisition_item_id、item_id、return_qty 等字段在本次确认流程中不会被修改, + // 所以可以直接使用 $existingRecord 中的值(都是只读字段) + $requisitionItemId = (int)$existingRecord->requisition_item_id; + $itemId = (int)$existingRecord->item_id; + $returnQty = (int)$existingRecord->return_qty; + $requisitionId = (int)$existingRecord->requisition_id; + + if ($requisitionItemId <= 0 || $itemId <= 0 || $returnQty <= 0 || $requisitionId <= 0) { + throw new BusinessException('归还申请记录缺少必要字段,无法处理'); + } + + // 6.1 处理领用明细 + /** @var \app\model\InvRequisitionItem|null $item */ + $item = InvRequisitionItem::find($requisitionItemId); + if (!$item) { + throw new BusinessException('未找到对应的领用明细记录'); + } + + $approvedQty = (int)($item->approved_qty ?? 0); + $returnedQty = (int)($item->returned_qty ?? 0); + + if ($approvedQty <= 0) { + throw new BusinessException('该物品尚未审批出库,无法归还'); + } + + if ($returnedQty + $returnQty > $approvedQty) { + throw new BusinessException('归还数量超过已借数量,无法确认'); + } + + $item->returned_qty = $returnedQty + $returnQty; + if ($item->returned_qty >= $approvedQty) { + $item->is_finished = 1; + } + $item->updated_at = time(); + + if ($item->save() === false) { + throw new BusinessException('更新领用明细失败'); + } + + // 6.2 处理库存(入库) + (new InvItemService())->stockIn($itemId, $returnQty); + + // 6.3 检查整张单是否已全部归还(只看非耗材) + $notFinishedCount = InvRequisitionItem::query() + ->where('requisition_id', $requisitionId) + ->where('is_consumable_snap', 0) + ->where('is_finished', 0) + ->count(); + + if ($notFinishedCount === 0) { + // 全部非耗材都已归还 + $affected = InvRequisition::where('id', $requisitionId)->update([ + 'is_returned' => 1, + 'updated_at' => time(), + ]); + + // Eloquent::update 返回的是影响行数,0 表示未更新到任何行 + if ($affected === 0) { + throw new BusinessException('更新领用单状态失败,可能记录不存在'); + } + } + + // 7. 返回处理结果(保持你原来的 postProcess 习惯) + return $this->postProcess(ucfirst($scene), $existingRecord); + }); + + } catch (BusinessException $e) { + throw $e; + } catch (\Throwable $e) { + throw new BusinessException("确认归还物品失败:" . $e->getMessage()); + } + } + + /** + * 构建查询配置 + * + * @param Request $request + * @param string $scene + * @return array + */ + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'returnConfirm': + $config['where']['id'] = $p['requisition_item_return_id']; + break; + case 'index': + $config['where']['status'] = 1; + $config['order']['sort'] = 'desc'; + $config['order']['id'] = 'desc'; + break; + case 'show': + $config['where']['id'] = $p['id']; + $config['where']['status'] = 1; + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } +} \ No newline at end of file diff --git a/app/common/service/InvRequisitionItemService.php b/app/common/service/InvRequisitionItemService.php new file mode 100644 index 0000000..13206b3 --- /dev/null +++ b/app/common/service/InvRequisitionItemService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/InvRequisitionService.php b/app/common/service/InvRequisitionService.php new file mode 100644 index 0000000..6c4e8e2 --- /dev/null +++ b/app/common/service/InvRequisitionService.php @@ -0,0 +1,212 @@ +validator; + } + + + /** + * 创建物品申请 前置 + * + * @param array $data + * @param Request $request + * @return array + */ + public function beforeServiceStore($data, Request $request): array + { + $user = (new UserService())->getUserInfo($request->user->uid, ''); + + if (empty($user['user']['id']) || empty($user['user']['name'])) { + throw new BusinessException('无法获取当前用户信息'); + } + + $data['order_no'] = $this->generateOrderNo(); + $data['applicant_id'] = (int)$user['user']['id']; + $data['applicant_name'] = (string)$user['user']['name']; + $data['leader_id'] = $user['user']['report_to']; + $data['status'] = InvRequisition::STATUS_LEADER; + + return $data; + } + + /** + * 处理物品申请 + * + * @param Request $request + * @param array $data + * @return array + */ + public function serviceStore(Request $request, array $data, string $scene = '') + { + try { + // 关联表增加事务处理 + return Db::transaction(function () use ($request, $data) { + $data = $this->preProcess('Store', $data, $request); + $dl = $this->getDataLimitFromRequest($request); + $created = $this->logicCreate($data, $dl); + // 处理物品申请项 + $this->processRequisitionItems($created, $data['items']); + // 处理物品申请附件 + $this->processRequisitionAttachmentItems($created, $data['attachment']); + return $this->postProcess('Store', $created); + }); + } catch (\Throwable $e) { + throw new BusinessException("创建物品申请失败:" . $e->getMessage()); + } + } + + /** + * 更新物品申请 + * + * @param Request $request + * @param array $data + * @return array + */ + public function serviceUpdate(Request $request, array $data, string $scene = '') + { + try { + // 关联表增加事务处理 + return Db::transaction(function () use ($request, $data) { + $data = $this->preProcess('Update', $data, $request); + $config = $this->buildQueryConfig($request, 'update'); + + // 获取申请项/附件 + $items = $data['items']; + $attachment = $data['attachment']; + unset($data['items']); + unset($data['attachment']); + + // 执行更新操作 + $this->logicUpdateBy($config, $data); + + // 获取更新后的完整记录 + $updated = $this->logicShow($config); + + // 重新创建新的申请项 + $this->processRequisitionItems((object)$updated, $items, true); + + // 重新创建新的附件 + $this->processRequisitionAttachmentItems((object)$updated, $attachment, true); + + return $this->postProcess('Update', $updated); + }); + } catch (\Throwable $e) { + throw new BusinessException("更新物品申请失败:" . $e->getMessage()."|".$e->getLine()."|".$e->getFile()); + } + } + + /** + * 处理物品申请项 + * + * @param Model $created + * @param array $items + * @return void + */ + private function processRequisitionItems(Model $created, array $items, bool $isUpdate = false): void + { + if ($isUpdate) { + $this->clearRequisitionItems($created->id); + } + + foreach ($items as $item) { + $itemData = [ + 'requisition_id' => $created->id, + 'item_id' => $item['item_id'], + 'item_name_snap' => $item['item_name_snap'], // 修改:使用 snap 字段 + 'spec_model_snap' => $item['spec_model_snap'], // 修改:使用 snap 字段 + 'unit_snap' => $item['unit_snap'], // 修改:使用 snap 字段 + 'is_consumable_snap' => $item['is_consumable_snap'], // 修改:使用 snap 字段 + 'request_qty' => (int) $item['request_qty'] ?? 1, // 动态获取数量,转为整型 + 'approved_qty' => (int) $item['request_qty'] ?? 1, // 初始审批数量等于申请数量 + 'remark' => $item['remark'] ?? '' + ]; + InvRequisitionItem::create($itemData); + } + } + + /** + * 处理物品申请项 + * + * @param Model $created + * @param array $items + * @return void + */ + private function processRequisitionAttachmentItems(Model $created, array $attachment, bool $isUpdate = false): void + { + if ($isUpdate) { + $this->clearRequisitionAttachmentItems($created->id); + } + + foreach ($attachment as $file) { + $attachmentData = [ + 'requisition_id' => $created->id, + 'file_name' => $file['original_name'], + 'file_path' => $file['file_path'] + ]; + InvRequisitionAttachment::create($attachmentData); + } + } + + /** + * 删除申请项 + * + * @param int $requisitionId + * @return void + */ + public function clearRequisitionItems(int $requisitionId): void + { + InvRequisitionItem::where('requisition_id', $requisitionId)->delete(); + } + + /** + * 删除申请项 + * + * @param int $requisitionId + * @return void + */ + public function clearRequisitionAttachmentItems(int $requisitionId): void + { + InvRequisitionAttachment::where('requisition_id', $requisitionId)->delete(); + } + + /** + * 生成申请单号 + * + * @return string + */ + private function generateOrderNo(): string + { + return 'RQ' . date('Ymd') . strtoupper(substr(uniqid(), -6)); + } +} \ No newline at end of file diff --git a/app/common/service/MenuBadgeService.php b/app/common/service/MenuBadgeService.php new file mode 100644 index 0000000..e46997b --- /dev/null +++ b/app/common/service/MenuBadgeService.php @@ -0,0 +1,178 @@ +叶),每一级返回“完整url + 角标” + * @param int $menuId 叶子或中间节点ID(directory/menu均可) + * @param int $leafCount 该节点需要向上累加的消息数(正数可表示新增) + * @param bool $includeHidden 是否包含隐藏/禁用菜单(默认 false:过滤 status != 1) + * @return array + */ + public static function buildBadgeChain( + int $menuId, + int $leafCount, + bool $includeHidden = false, + bool $forceIncludeSelf = true + ): array { + // 取到从“叶->根”的节点列表 + $nodes = self::getAncestorNodesIterative($menuId, $includeHidden, $forceIncludeSelf); + if (empty($nodes)) { + return []; + } + + // 调整为“根->叶” + $nodes = array_reverse($nodes); + $segments = []; + $result = []; + $leafId = $menuId; + + foreach ($nodes as $n) { + $isLeaf = ((int)$n['id'] === $leafId); + + // 中间层的 permission 不展示(但若有内部url段,仍需累计到segments) + if (($n['type'] ?? '') === 'permission' && !$isLeaf) { + // 若这是 internal 且有 url 段,仍拼到段里,便于子级正确拼接 + $openType = strtolower((string)($n['open_type'] ?? 'internal')); + if ($openType === 'internal') { + $seg = trim((string)($n['url'] ?? ''), '/'); + if ($seg !== '') { + $segments[] = $seg; + } + } + continue; + } + + // 计算完整URL(internal 按段拼接;external/iframe 直接用 link/url) + $fullUrl = self::composeFullUrl($segments, $n); + + // 叶子(传入ID)必须返回;其他节点照常 + if ($isLeaf || ($n['type'] ?? '') !== 'permission') { + $result[] = [ + 'id' => (int)$n['id'], + 'name' => (string)$n['name'], + 'type' => (string)$n['type'], + 'url' => $fullUrl, + 'badge' => (int)$leafCount, + ]; + } + } + + return $result; + } + + /** + * 批量聚合多个叶子节点的角标,把同一路径处的角标相加(父级=所有子级之和) + * @param array $items + * @param bool $includeHidden + * @return array + */ + public static function aggregateChains(array $items, bool $includeHidden = false): array + { + $acc = []; // key = url + + foreach ($items as $it) { + $menuId = (int)($it['menu_id'] ?? 0); + $leafCount = (int)($it['count'] ?? 0); + if ($menuId <= 0 || $leafCount === 0) { + continue; + } + + $chain = self::buildBadgeChain($menuId, $leafCount, $includeHidden); + foreach ($chain as $node) { + $key = $node['url']; + if (!isset($acc[$key])) { + $acc[$key] = ['url' => $node['url'], 'badge' => 0]; + } + $acc[$key]['badge'] += $node['badge']; + } + } + + // 父级在前(更短的路径在前),便于前端逐级渲染 + usort($acc, function ($a, $b) { + return substr_count($a['url'], '/') <=> substr_count($b['url'], '/'); + }); + + return array_values($acc); + } + + /** + * 迭代法:从叶子一路查到根(MySQL版本无关) + * $forceIncludeSelf=true 时,无论叶子 status 是否为1,都会把叶子纳入; + * 但祖先节点仍按 $includeHidden 规则过滤。 + */ + private static function getAncestorNodesIterative( + int $id, + bool $includeHidden = false, + bool $forceIncludeSelf = true + ): array { + $cols = ['id', 'parent_id', 'name', 'type', 'url', 'open_type', 'link', 'is_hide', 'status']; + $nodes = []; + $seen = []; + $cur = $id; + $maxHop = 32; // 防环 + + $isFirst = true; + for ($i = 0; $i < $maxHop && $cur > 0; $i++) { + /** @var object|null $row */ + $row = Db::table('resources')->select($cols)->where('id', $cur)->first(); + if (!$row) break; + + $rowArr = (array)$row; + + if (!$includeHidden) { + // 叶子节点:若 $forceIncludeSelf=true 则无视 status 过滤;祖先照常过滤 + if (!$isFirst && (int)$rowArr['status'] !== 1) { + break; + } + if ($isFirst && !$forceIncludeSelf && (int)$rowArr['status'] !== 1) { + // 如果明确不强制包含叶子,叶子也按规则过滤 + break; + } + } + + $nodes[] = $rowArr; + + if (in_array($cur, $seen, true)) { + break; // 防环 + } + $seen[] = $cur; + + $cur = (int)$rowArr['parent_id']; + $isFirst = false; + } + + return $nodes; // 叶 -> 根 + } + + /** + * 组装“完整路由” + * - internal:把父级段 + 自身 url 段拼成形如 /a/b/c + * - external/iframe:直接用 link(若无则退回自身 url) + * - 忽略空段(避免出现重复斜杠) + */ + private static function composeFullUrl(array &$segments, array $node): string + { + $openType = strtolower((string)($node['open_type'] ?? 'internal')); + + // 外链或 iframe:直接返回 link/url,不再参与分段拼接 + if ($openType !== 'internal') { + $target = trim((string)($node['link'] ?? ''), " \t\r\n"); + if ($target === '') { + $target = (string)($node['url'] ?? ''); + } + return $target; + } + + // internal:把自身 url 作为一个“段”拼到前面来的 segments + $seg = trim((string)($node['url'] ?? ''), '/'); + if ($seg !== '') { + $segments[] = $seg; + } + + return '/' . implode('/', $segments); + } +} \ No newline at end of file diff --git a/app/common/service/MessageNotificationsService.php b/app/common/service/MessageNotificationsService.php new file mode 100644 index 0000000..3d49a6a --- /dev/null +++ b/app/common/service/MessageNotificationsService.php @@ -0,0 +1,286 @@ +validator; + } + + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'index': + $config['order']['id'] = 'desc'; + break; + case 'show': + $config['where']['id'] = $p['id']; + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } + + + public function serviceDestroy(Request $request) + { + $config = $this->buildQueryConfig($request, 'destroy'); + $deleted = (new MessageNotificationsLogic())->logicDeleteBy($config); + return $this->postProcess('Destroy', $deleted); + } + + /** + * 添加前置处理 + */ + public function servicePush(Request $request, $data) + { + $pushData = [ + 'sender_id' => $request->user->uid, + 'sender_type' => 'admin', + 'title' => $data['title'], + 'content' => $data['content'], + 'scene' => 1, + 'recipient' => $data['recipient'], + 'push_method' => 1 + ]; + $msgId = (new MessageNotificationsService())->pushNotice($pushData); + return ['msg_id' => $msgId]; + } + + public function afterServiceIndex($data) + { + $data->each(function (&$item) { + $temp = explode(',', $item->recipient); + $item->recipient_list = User::whereIn('id', $temp)->pluck('name')->toArray(); + $item->scene_name = MessageNotifications::SCENE[$item->scene]; + $item->push_method_name = MessageNotifications::PUSH_METHOD[$item->push_method]; + }); + unset($item); + return $data; + } + + public function afterServiceShow($data) + { + $temp = explode(',', $data->recipient); + $data->recipient_list = User::whereIn('id', $temp)->pluck('name')->toArray(); + $data->scene_name = MessageNotifications::SCENE[$data->scene]; + $data->push_method_name = MessageNotifications::PUSH_METHOD[$data->push_method]; + return $data; + } + + + + // ====== ====== ====== 通知操作的处理 ====== ====== ====== + /** + * 推送消息 + */ + public function pushNotice(array $data = [], string $scene = "Default"): int + { + // 拼装数据(可能需要处理 消息标题、内容等等数据,可自行处理) + $pushData = $this->assemblyProcess($scene, $data); + // 写入消息 + $msgId = $this->insertMessage($pushData); + // 推送 + $this->pushWs($msgId, $pushData); + return $msgId; + } + + /** + * 执行推送 + */ + public function pushWs(int $msgId, array $pushData) + { + foreach ($pushData['recipient'] as $userId) { + // 查询消息数据 + $config['model'] = MessageUserStatus::class; + $config['order']['id'] = 'desc'; + $config['with']['message'] = ['sender_id', 'sender_type', 'msg_no', 'title', 'content', 'scene', 'created_at']; + $config['where']['uid'] = $userId; + $config['where']['msg_id'] = $msgId; + $config['where']['status'] = 0; + $wsData = (new MessageUserStatusService())->logicShow($config)->toArray(); + + // 组装数据 + if (isset($wsData['message']) && $wsData['message']) { + $wsData['message']['sender_name'] = User::where('id', $wsData['message']['sender_id'])->value('name') ?: '系统'; + $wsData['message']['scene_name'] = MessageNotifications::SCENE[$wsData['message']['scene']]; + $wsData['message']['created_at'] = date('Y-m-d H:i:s', $wsData['message']['created_at']); + } + + // 获取未读消息数 + $counts = MessageUserStatus::where('uid', $userId)->where('status', 0)->count('id'); + $data = [ + 'notice_data' => $wsData, + 'unread_counts' => $counts + ]; + + // 执行推送 + (new \app\common\utils\WsEvents())->pushWebsocket('notify.receive', $userId, $data); + } + return true; + } + + /** + * 插入消息 + * @param array $data + * @return int + */ + public function insertMessage(array $data): int + { + // 写消息 + $msgId = $this->createMessage([ + 'sender_id' => $data['sender_id'], + 'sender_type' => $data['sender_type'], + 'title' => $data['title'], + 'content' => $data['content'], + 'scene' => $data['scene'], + 'recipient' => implode(',', $data['recipient']), + 'push_method' => $data['push_method'], + ]); + // 写用户状态 + $this->addUserStatus($msgId, $data['recipient']); + return $msgId; + } + + /** + * 数据拼装方法调用 + */ + protected function assemblyProcess(string $op, mixed $data): mixed + { + $hook = "assembly{$op}"; + return method_exists($this, $hook) + ? $this->{$hook}($data) + : $data; + } + + /** + * 默认数据拼装 + */ + public function assemblyDefault(array $data) + { + return [ + 'sender_id' => isset($data['sender_id']) && $data['sender_id'] ? $data['sender_id'] : 0, + 'sender_type' => isset($data['sender_type']) && $data['sender_type'] ? $data['sender_type'] : 'system', + 'title' => isset($data['title']) && $data['title'] ? $data['title'] : '系统通知', + 'content' => isset($data['content']) && $data['content'] ? $data['content'] : '系统通知-默认', + 'scene' => isset($data['scene']) && $data['scene'] ? $data['scene'] : 1, + 'recipient' => isset($data['recipient']) && count($data['recipient']) > 0 ? $data['recipient'] : $this->getAllUserIds(), + 'push_method' => isset($data['push_method']) && $data['push_method'] ? $data['push_method'] : 0, + ]; + } + + // *** *** 可以扩展数据组拼装方法 *** *** + + // ====== ====== ====== 通知操作的基础方法 ====== ====== ====== + + /** + * 创建一条消息 + */ + public function createMessage(array $data): int + { + $msgId = Db::table('message_notifications')->insertGetId([ + 'msg_no' => generateMsgId(), + 'sender_id' => $data['sender_id'], + 'sender_type' => $data['sender_type'], + 'title' => $data['title'], + 'content' => $data['content'], + 'scene' => $data['scene'], + 'recipient' => $data['recipient'], + 'push_method' => $data['push_method'], + 'created_at' => time(), + ]); + + return $msgId; + } + + /** + * 批量插入用户消息状态 + */ + public function addUserStatus(int $msgId, array $uids) + { + $rows = []; + foreach ($uids as $uid) { + $rows[] = [ + 'msg_id' => $msgId, + 'uid' => $uid, + 'status' => 0, + 'read_at' => null + ]; + } + if ($rows) { + Db::table('message_user_status')->insert($rows); + } + } + + /** + * 标记消息已读 + */ + public function markAsRead(array|string $msgIds, int $uid) + { + $query = Db::table('message_user_status')->where('uid', $uid)->where('status', 0); + if (is_array($msgIds)) { + $query->whereIn('msg_id', $msgIds); + } + return $query->update([ + 'status' => 1, + 'read_at' => time() + ]); + } + + /** + * 获取所有用户ID + */ + public function getAllUserIds(): array + { + return User::where('status', 1)->pluck('id')->toArray(); + } + + + public function getUserEmailsById(array $con = []): array + { + $user = User::where('status', 1); + if (count($con) > 0) { + return $user->whereIn('id', $con)->where('email', '!=', '')->pluck('email')->toArray(); + } + return $user->where('email', '!=', '')->pluck('email')->toArray(); + } +} \ No newline at end of file diff --git a/app/common/service/MessageUserStatusService.php b/app/common/service/MessageUserStatusService.php new file mode 100644 index 0000000..ca324dc --- /dev/null +++ b/app/common/service/MessageUserStatusService.php @@ -0,0 +1,136 @@ +validator; + } + + + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'unread': + $config['order']['id'] = 'desc'; + $config['with']['message'] = ['sender_id', 'sender_type', 'msg_no', 'title', 'content', 'scene', 'created_at']; + $config['where']['uid'] = $request->user->uid; + $config['where']['status'] = 0; + break; + case 'index': + $config['order']['id'] = 'desc'; + $config['with']['message'] = ['sender_id', 'sender_type', 'msg_no', 'title', 'content', 'scene', 'created_at']; + $config['where']['uid'] = $request->user->uid; + break; + case 'show': + $config['where']['id'] = $p['id']; + $config['with']['message'] = ['sender_id', 'sender_type', 'msg_no', 'title', 'content', 'scene', 'created_at']; + $config['where']['uid'] = $request->user->uid; + break; + case 'store': + break; + case 'update': + case 'read': + $config['where']['id'] = $p['id']; + $config['where']['msg_id'] = $p['msg_id']; + $config['where']['uid'] = $request->user->uid; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + $config['where']['uid'] = $request->user->uid; + break; + } + + return $config; + } + + public function serviceListUnRead(Request $request) + { + $config = $this->buildQueryConfig($request, 'unread'); + $result = $this->logicListAll($config); + return $this->postProcess('ListUnRead', $result); + } + + public function serviceRead(Request $request, array $data = []) + { + return $this->noticeRead($request->post('msg_ids'), $request->user->uid); + } + + public function noticeRead(array|string $msgIds, int $uid): bool + { + $model = MessageUserStatus::where('uid', $uid)->where('status', 0); + if (is_array($msgIds)) { + $model->whereIn('msg_id', $msgIds); + } + $udata['read_at'] = time(); + $udata['status'] = 1; + return $model->update($udata); + } + + + protected function afterServiceIndex($res) + { + $res->each(function (&$item) { + if (isset($item->message)) { + $item->message->sender_name = User::where('id', $item->message->sender_id)->value('name') ?: '系统'; + $item->message->scene_name = MessageNotifications::SCENE[$item->message->scene]; + } + }); + unset($item); + return $res; + } + + protected function afterServiceShow($res) + { + if (isset($res->message)) { + $res->message->sender_name = User::where('id', $res->message->sender_id)->value('name') ?: '系统'; + $res->message->scene_name = MessageNotifications::SCENE[$res->message->scene]; + } + // 标记已读 + if ($res->status == 0) { + $this->noticeRead([$res->msg_id], $res->uid); + } + return $res; + } + + protected function afterServiceListUnRead($res) + { + $res->each(function (&$item) { + if (isset($item->message)) { + $item->message->sender_name = User::where('id', $item->message->sender_id)->value('name') ?: '系统'; + $item->message->scene_name = MessageNotifications::SCENE[$item->message->scene]; + } + }); + unset($item); + return $res; + } +} \ No newline at end of file diff --git a/app/common/service/OssService.php b/app/common/service/OssService.php new file mode 100644 index 0000000..1aacc1c --- /dev/null +++ b/app/common/service/OssService.php @@ -0,0 +1,113 @@ +accessKeyId = getenv('OSS_ACCESSID'); + $this->accessKeySecret = getenv('OSS_ACCESSSECRET'); + $this->bucket = getenv('OSS_BUCKET'); + $this->endpoint = getenv('OSS_ENDPOINT'); + $this->ossClient = new OssClient($this->accessKeyId, $this->accessKeySecret, $this->endpoint); + } + + /** + * 上传图片到 OSS + * @param string $filePath 本地文件路径 + * @param string $objectKey OSS上存储的对象名称 + * @return string 图片的 URL + */ + public function uploadImage($filePath, $objectKey) + { + try { + // 上传文件到 OSS + $this->ossClient->uploadFile($this->bucket, $objectKey, $filePath); + // 构造图片的私有 URL + $url = $this->generatePrivateUrl($objectKey); + return $url; + } catch (OssException $e) { + // 错误处理 + throw new \RuntimeException('OSS Error: ' . $e->getMessage()); + } catch (\Throwable $th) { + // 错误处理 + throw new BusinessException('Error: ' . $th->getMessage()); + } + } + + /** + * 生成图片的私有访问 URL + * @param string $objectKey + * @return string + */ + public function generatePrivateUrl($objectKey, $expireTime = 3600) + { + // 生成 OSS 私有 URL(有效期为 3600 秒) + return $this->ossClient->signUrl($this->bucket, $objectKey, $expireTime); + } + + /** + * 删除 OSS 上的文件 + * @param string $objectKey OSS上存储的对象名称 + * @return bool 删除是否成功 + */ + public function deleteFile($objectKey) + { + try { + // 删除 OSS 上的文件 + $this->ossClient->deleteObject($this->bucket, $objectKey); + return true; + } catch (OssException $e) { + // 错误处理 + throw new \RuntimeException('OSS Delete Error: ' . $e->getMessage()); + } catch (\Throwable $th) { + // 错误处理 + throw new BusinessException('Delete Error: ' . $th->getMessage()); + } + } + + /** + * 获取对应的 MIME 类型 + */ + public function getMimeTypes($extensions) + { + $mimeMap = [ + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'bmp' => 'image/bmp', + 'webp' => 'image/webp', + 'pdf' => 'application/pdf', + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt' => 'application/vnd.ms-powerpoint', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'txt' => 'text/plain', + 'zip' => 'application/zip', + 'rar' => 'application/x-rar-compressed' + ]; + + $mimes = []; + foreach ($extensions as $ext) { + if (isset($mimeMap[$ext])) { + $mimes[] = $mimeMap[$ext]; + } + } + + return array_unique($mimes); + } +} \ No newline at end of file diff --git a/app/common/service/PermService.php b/app/common/service/PermService.php new file mode 100644 index 0000000..16030b1 --- /dev/null +++ b/app/common/service/PermService.php @@ -0,0 +1,65 @@ +logic = (new PermLogic()); + } + + // 用户资源缓存key + protected static function cacheKey(int $userId, string $systemCode): string + { + return "user_resources_{$userId}_{$systemCode}"; + } + + /** + * 获取用户所有资源(合并角色+直接分配),结果缓存 + */ + public function getUserResources(int $userId, string $systemCode) + { + $cacheKey = self::cacheKey($userId, $systemCode); + $cached = Cache::get($cacheKey); + if ($cached !== null) { + return $cached; + } + // 调用 Logic 层获取资源 + $resources = $this->logic->getUserResources($userId, $systemCode); + // 缓存结果 + Cache::set($cacheKey, $resources, 3600); + return $resources; + } + + /** + * 构建目录/菜单树,节点包含权限与打开方式 + */ + public function getMenuTree(int $userId, string $systemCode): array + { + // 调用 Logic 层处理菜单构建 + $resources = $this->getUserResources($userId, $systemCode); + return $this->logic->buildTree($resources); + } + + /** + * 获取用户所有权限code + */ + public function getPermissionCodes(int $userId, string $systemCode): array + { + return $this->logic->getPermissionCodes($userId, $systemCode); + } + + /** + * 清除资源相关缓存 + */ + public function clearCache(int $userId, string $systemCode): void + { + Cache::delete(self::cacheKey($userId, $systemCode)); + } +} \ No newline at end of file diff --git a/app/common/service/PermService_bak.php b/app/common/service/PermService_bak.php new file mode 100644 index 0000000..a2dcc27 --- /dev/null +++ b/app/common/service/PermService_bak.php @@ -0,0 +1,109 @@ +find($userId); + if (!$user) { + return collect(); + } + + $all = collect(); + foreach ($user->roles as $role) { + $all = $all->merge($role->resources); + } + $all = $all->merge($user->resources); + $unique = $all->unique('id')->values(); + + Cache::set($cacheKey, $unique, 3600); + return $unique; + } + + /** + * 构建目录/菜单树,节点包含权限与打开方式 + */ + public static function getMenuTree(int $userId): array + { + $resources = self::getUserResources($userId); + $menus = $resources + ->filter(fn($r) => in_array($r->type, ['directory','menu'])) + ->values(); + return self::buildTree($menus, $resources); + } + + protected static function buildTree($nodes, $allResources, int $parentId = 0): array + { + $tree = []; + foreach ($nodes as $node) { + if ($node->parent_id !== $parentId) continue; + + $children = self::buildTree($nodes, $allResources, $node->id); + + $authList = $allResources + ->filter(fn($r) => $r->type === 'permission' && $r->parent_id === $node->id) + ->map(fn($r) => [ + 'id' => $r->id, + 'title' => $r->name, + 'auth_mark' => $r->code, + ])->values()->all(); + + $tree[] = [ + 'id' => $node->id, + 'name' => $node->name, + 'path' => $node->url, + 'meta' => [ + 'title' => $node->name, + 'icon' => $node->icon, + 'keepAlive' => true, + 'authList' => $authList, + 'openType' => $node->open_type, // 'internal' | 'external' | 'iframe' + ], + 'children' => $children, + ]; + } + return $tree; + } + + /** + * 获取用户所有权限code + */ + public static function getPermissionCodes(int $userId): array + { + return self::getUserResources($userId) + ->filter(fn($res) => $res->type === 'permission') + ->pluck('code') + ->unique() + ->values() + ->all(); + } + + /** + * 清除资源相关缓存 + */ + public static function clearCache(int $userId): void + { + Cache::delete(self::cacheKey($userId)); + } +} \ No newline at end of file diff --git a/app/common/service/PurchaseOrderItemsService.php b/app/common/service/PurchaseOrderItemsService.php new file mode 100644 index 0000000..3ead14c --- /dev/null +++ b/app/common/service/PurchaseOrderItemsService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/PurchaseOrdersService.php b/app/common/service/PurchaseOrdersService.php new file mode 100644 index 0000000..64ca27e --- /dev/null +++ b/app/common/service/PurchaseOrdersService.php @@ -0,0 +1,357 @@ +validator; + } + + + public function getLogic() + { + return new PurchaseOrdersLogic(); + } + + /** + * 获取前置预处理数据 + * + * @param array $data 原始返回值 + * @return array 处理后的返回值 + */ + public function preQueryData(array $data) + { + $result = match ($data['scene']) { + 'pi_no' => $this->getLogic()->getPiList(), + 'order_no' => $this->getLogic()->getOrderNoListByPiNo($data['pi_no']), + 'order_items' => $this->getLogic()->getOrderItemsListByOrderNo($data['order_id']), + default => $data, + }; + return $this->postProcess('PreData', $result->toArray()); + } + + public function afterServicePreData(array $data) + { + foreach ($data as $key => &$item) { + if (isset($item['biz_date'])) { + $item['biz_date'] = date('Y-m-d', $item['biz_date']); + } + } + unset($item); + return $data; + } + + public function beforeServiceStore(array $data, Request $request) + { + $data['order_no'] = generateStockNo('CG'); + $data['buyer_id'] = $request->user->uid; + $data['buyer_name'] = User::where('id', $request->user->uid)->value('name'); + if ($data['is_draft'] == 0) { + $data['status'] = 1; + } + return $data; + } + + public function serviceStore(Request $request, array $data = [], string $scene = 'store') + { + // 使用事务确保数据一致性 + return Db::transaction(function () use ($request, $data, $scene) { + $data = $this->preProcess(ucfirst($scene), $data, $request); + $dl = $this->getDataLimitFromRequest($request); + // 创建采购单 + $created = $this->logicCreate($data, $dl); + if (isset($data['stock_order'])) { + foreach ($data['stock_order'] as $order) { + foreach ($order['stock_items'] as $item) { + // 创建采购单明细 + PurchaseOrderItems::create([ + 'purchase_order_id' => $created->id, + 'stock_id' => $order['stock_orders_id'], + 'stock_items_id' => $item['stock_order_items_id'], + 'stock_items_uqid' => $item['unique_id'], + ]); + } + } + } + return $this->postProcess(ucfirst($scene), $created); + }); + } + + /** + * 构建查询配置 + * + * @param Request $request + * @param string $scene + * @return array + */ + protected function buildQueryConfig(Request $request, string $scene = 'index'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + + switch ($scene) { + case 'index': + $config['order']['id'] = 'desc'; + break; + case 'update': + $config['where']['id'] = $p['id'] ?? 0; + break; + case 'show': + $config['where']['id'] = $p['id']; + $config['with'] = [ + 'items', 'items.stockOrder', 'items.stockOrderItems' + ]; + break; + } + + return $config; + } + + /** + * 删除采购订单 + * + * @param Request $request + * @return mixed + */ + public function serviceDestroy(Request $request) + { + // 使用事务确保数据一致性 + return Db::transaction(function () use ($request) { + // 构建查询条件 + $config = $this->buildQueryConfig($request, 'show'); + + // 软删除采购单明细 + $order = $this->logicShow($config); + if ($order && $order->items) { + foreach ($order->items as $item) { + $item->delete(); + } + } + + // 软删除采购单主表 + $res = $order->delete(); + + return $this->postProcess('Destroy', $res); + }); + } + + /** + * 创建采购单成功后处理 + */ + protected function afterServiceStore($res) + { + if ($res->is_draft == 0) { + $this->startApproval($res, ['resubmitted'=>0]); + } + return $res; + } + + public function startApproval($item) + { + $appro = new \app\common\utils\approval\service\ApprovalFlowService(); + $appro->start('purchase_orders', 'purchase_orders', $item->id, ['resubmitted'=>0], $item->buyer_id); + } + + public function beforeServiceUpdate(array $data, Request $request) + { + if ($data['is_draft'] == 0) { + $data['status'] = 1; + } + return $data; + } + + /** + * 更新采购单 + * + * @param Request $request + * @param array $data + * @return mixed + */ + public function serviceUpdate(Request $request, array $data = [], string $scene = 'update') + { + // 使用事务确保数据一致性 + return Db::transaction(function () use ($request, $data, $scene) { + $data = $this->preProcess(ucfirst($scene), $data, $request); + + // 构建查询条件 + $config = $this->buildQueryConfig($request, $scene); + + // 更新主表信息 + $udata = $data; + unset($udata['stock_order']); + unset($udata['is_resubmit']); + unset($udata['approval_desc']); + $this->logicUpdateBy($config, $udata); + + // 获取更新后的数据 + $order = $this->logicShow($config); + + // 更新采购单明细 + if (isset($data['stock_order'])) { + // 先删除原有的明细 + PurchaseOrderItems::where('purchase_order_id', $order->id)->delete(); + + // 重新创建明细 + foreach ($data['stock_order'] as $orderData) { + foreach ($orderData['stock_items'] as $item) { + PurchaseOrderItems::create([ + 'purchase_order_id' => $order->id, + 'stock_id' => $orderData['stock_orders_id'], + 'stock_items_id' => $item['stock_order_items_id'], + 'stock_items_uqid' => $item['unique_id'], + ]); + } + } + } + + return $this->postProcess( + ucfirst($scene), + $order, + [ + 'is_resubmit'=>$data['is_resubmit'], + 'approval_desc'=>$data['approval_desc'] + ] + ); + }); + } + + protected function afterServiceUpdate($res, $data) + { + if ($data['is_resubmit'] == 0 && $res->status == 1) { + $this->startApproval($res); + } + if ($data['is_resubmit'] == 1) { + $instance = \app\common\model\ApprovalInstances::where('biz_id', $res->id)->where('flow_type', 'purchase_orders')->first(); + if ( $instance === null) { + throw new BusinessException('未找到审批实例'); + } + // 审批服务 + $appro = new \app\common\utils\approval\service\ApprovalFlowService(); + // 重新提交 + $appro->resubmit($instance->id, $instance->initiator_id, ['resubmitted'=>1], $data['approval_desc']); + // 添加到讨论 + if (!empty($data['approval_desc'])) { + $appro->postDiscussion($instance->id, $instance->initiator_id, $data['approval_desc'], false, [], 'name'); + } + } + return $res; + } + + + public function serviceResubmit(Request $request, array $data = []) + { + $p = $request->all(); + $config = []; + $config['model'] = $this->modelClass; + $config['where']['id'] = $p['id']; + $config['where']['buyer_id'] = $request->user->uid; + $info = $this->logicShow($config); + if ($info === null) { + throw new BusinessException('未找到采购单'); + } + if ($info->status != 3) { + throw new BusinessException('该状态不能重新提交'); + } + $instance = \app\common\model\ApprovalInstances::where('biz_id', $info->id)->where('flow_type', 'purchase_orders')->first(); + if ( $instance === null) { + throw new BusinessException('未找到审批实例'); + } + // 审批服务 + $appro = new \app\common\utils\approval\service\ApprovalFlowService(); + // 重新提交 + $result = $appro->resubmit($instance->id, $instance->initiator_id, ['resubmitted'=>1], $data['remark']); + // 添加到讨论 + if (!empty($data['remark'])) { + $appro->postDiscussion($instance->id, $instance->initiator_id, $data['remark'], false, [], 'name'); + } + + $info->status = 1; + $info->save(); + return $result; + } + + + public function updateStatus(array $data = []) + { + + $info = PurchaseOrders::where('id', $data['id'])->first(); + if ($info === null) { + throw new BusinessException('未找到采购单'); + } + $info->status = $data['status']; + return $info->save(); + } + + + public function formatData($data) + { + $contractFile = $data['contract_file'] ? explode(',', $data['contract_file']) : []; + $contractFileUrl = []; + if (count($contractFile) > 0) { + foreach ($contractFile as $file) { + $contractFileUrl[] = (new \app\common\service\OssService)->generatePrivateUrl($file); + } + } + $result = [ + 'id' => $data['id'], + 'order_no' => $data['order_no'], + 'pi_no' => $data['pi_no'], + 'buyer_id' => $data['buyer_id'], + 'buyer_name' => $data['buyer_name'], + 'contract_file' => $contractFile, + 'contract_file_url' => $contractFileUrl, + 'status' => $data['status'], + 'remark' => $data['remark'] + ]; + $temps = []; + if (isset($data['items']) && count($data['items']) > 0) { + foreach ($data['items'] as $record) { + if (!in_array($record['stockOrder']['id'], array_keys($temps))) { + $temps[$record['stockOrder']['id']] = [ + 'id' => $record['stockOrder']['id'], + 'order_no' => $record['stockOrder']['order_no'], + 'salesperson_snapshot' => $record['stockOrder']['salesperson_snapshot'], + 'biz_date' => date('Y-m-d', $record['stockOrder']['biz_date']) + ]; + } + $temps[$record['stockOrder']['id']]['stock_order_items'][] =[ + 'id' => $record['stockOrderItems']['id'], + 'model_snapshot' => $record['stockOrderItems']['model_snapshot'], + 'unique_id' => $record['stockOrderItems']['unique_id'], + ]; + } + } + $result['stock_order'] = array_values($temps); + return $result; + } +} \ No newline at end of file diff --git a/app/common/service/ResourceService.php b/app/common/service/ResourceService.php new file mode 100644 index 0000000..2d093f8 --- /dev/null +++ b/app/common/service/ResourceService.php @@ -0,0 +1,130 @@ +validator; + } + + + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'default': + $config['where']['system_id'] = ['in', [0, $p['system_id']]]; + $config['where']['status'] = 1; + $config['order']['sort'] = 'asc'; + $config['order']['id'] = 'asc'; + break; + case 'menuList': + $config['where']['status'] = 1; + $config['where']['type'] = ['!=', 'permission']; + $config['order']['sort'] = 'asc'; + $config['order']['id'] = 'asc'; + break; + case 'menuTreeList': + $config['where']['system_id'] = ['in', [0, $p['system_id']]]; + $config['where']['status'] = 1; + $config['order']['sort'] = 'asc'; + $config['order']['id'] = 'asc'; + break; + case 'index': + $config['where']['status'] = 1; + $config['order']['sort'] = 'asc'; + $config['order']['id'] = 'asc'; + break; + case 'show': + $config['where']['id'] = $p['id']; + $config['where']['status'] = 1; + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } + + public function serviceMenuList(Request $request) + { + $config = $this->buildQueryConfig($request, 'menuList'); + $result = $this->logicListAll($config); + return $this->postProcess('MenuList', $result); + } + + public function serviceMenuTreeList(Request $request) + { + $config = $this->buildQueryConfig($request, 'menuTreeList'); + $result = (new ResourceLogic())->logicListAllResources($request, $config); + return $this->postProcess('MenuTreeList', $result); + } + + + protected function afterServiceDefault($data) + { + $data = Tree::buildMenuTreeWithReference($data->toArray()); + return $data; + } + + protected function afterServiceMenuList($data) + { + $data = Tree::buildMenuTree($data->toArray()); + return $data; + } + + protected function afterServiceMenuTreeList($data) + { + $data = Tree::buildMenuTree($data->toArray()); + return $data; + } + + + public function beforeServiceStore($data, Request $request) + { + $data['parent_id'] = $data['parent_id'] != '' ? $data['parent_id'] : 0; + $data['system_id'] = $data['system_id'] != '' ? $data['system_id'] : 0; + return $data; + } + + public function beforeServiceUpdate($data, Request $request) + { + $data['parent_id'] = $data['parent_id'] != '' ? $data['parent_id'] : 0; + $data['system_id'] = $data['system_id'] != '' ? $data['system_id'] : 0; + return $data; + } +} \ No newline at end of file diff --git a/app/common/service/StockOrderAccountsService.php b/app/common/service/StockOrderAccountsService.php new file mode 100644 index 0000000..cc455a7 --- /dev/null +++ b/app/common/service/StockOrderAccountsService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/StockOrderItemOptionsService.php b/app/common/service/StockOrderItemOptionsService.php new file mode 100644 index 0000000..2e6e48e --- /dev/null +++ b/app/common/service/StockOrderItemOptionsService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/StockOrderItemsService.php b/app/common/service/StockOrderItemsService.php new file mode 100644 index 0000000..9130bdc --- /dev/null +++ b/app/common/service/StockOrderItemsService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/StockOrdersFilesService.php b/app/common/service/StockOrdersFilesService.php new file mode 100644 index 0000000..3b8c91b --- /dev/null +++ b/app/common/service/StockOrdersFilesService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/StockOrdersService.php b/app/common/service/StockOrdersService.php new file mode 100644 index 0000000..7ee30a4 --- /dev/null +++ b/app/common/service/StockOrdersService.php @@ -0,0 +1,1132 @@ +validator; + } + + + protected function buildQueryConfig(Request $request, string $scene = 'index'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'index': + + // ====== ====== 筛选 ====== ====== + // 业务id + if (isset($p['salesperson_id']) && $p['salesperson_id']) { + $config['where']['salesperson_id'] = $p['salesperson_id']; + } + // 初、终版 + $config['where']['is_final'] = $p['is_final'] ?? 0; + // 关键字 + if (isset($p['keyword']) && $p['keyword']) { + $k = '%' . $p['keyword'] . '%'; + foreach (['pi_no', 'order_no', 'customer_snapshot'] as $col) { + $config['orWhere'][] = [$col, 'like', $k]; + } + } + + $config['order']['id'] = 'desc'; + + // $config['where']['salesperson_id'] = $request->user->uid; + break; + case 'print': + case 'approval': + $config['where']['id'] = $p['id']; + $config['with'][] = 'files'; // 添加文件附件关联 + break; + case 'detail': + case 'show': + $config['where']['id'] = $p['id']; + + $config['with'][] = 'items'; + $config['with'][] = 'items.options'; + $config['with'][] = 'items.moneyValues'; + $config['with'][] = 'accounts'; + $config['with'][] = 'moneyValues'; + $config['with'][] = 'salesperson'; + $config['with'][] = 'files'; // 添加文件附件关联 + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } + + public function beforeServiceStore($data, Request $request) + { + $userId = $request->user->uid; + // 检查绑定 + (new UserService())->checkBind($userId); + // 保存不检查文件上传 + if ($data['approval_status'] != 0) { + $this->validateRequiredFiles($data); + } + // 终版备货单时,至少一个车辆填写了vin + if ($data['is_final'] == 1) { + $hasVin = false; + foreach ($data['veh_list'] as $k => $v) { + if ($v['vin'] != '') { + $hasVin = true; + break; + } + } + if (!$hasVin) { + throw new BusinessException('至少需要为一辆车辆填写vin,才能提交审核'); + } + } + $data['order_no'] = generateStockNo(); + $data['biz_date'] = strtotime($data['biz_date']); + $data['eta_ship_date'] = strtotime($data['eta_ship_date']); + $data['latest_arrival_date'] = strtotime($data['latest_arrival_date']); + $data['salesperson_id'] = $userId; + $data['salesperson_snapshot'] = User::where('id', $userId)->value('name') ?? ''; + $data['approval_status'] = $data['approval_status'] == 1 ? 1 : 0; + $data['pick_up_date'] = $data['pick_up_date'] ? strtotime($data['pick_up_date']) : 0; + $data['collect_all_date'] = $data['collect_all_date'] ? strtotime($data['collect_all_date']) : 0; + return $data; + } + + /** + * 验证创建备货单时必需的文件是否已提供 + * + * @param array $data 提交的数据 + * @throws BusinessException 当必需的文件缺失或为空时抛出异常 + */ + private function validateRequiredFiles(array $data): void + { + $requiredFiles = [ + 'contract' => '合同', + 'transaction_slip' => '水单', + 'pi_doc' => 'PI' + ]; + + foreach ($requiredFiles as $fileType => $fileLabel) { + if (!isset($data[$fileType]) || !is_array($data[$fileType]) || empty($data[$fileType])) { + throw new BusinessException("创建备货单时{$fileLabel}文件不能为空"); + } + + // 检查文件数组中的每个条目是否都有有效的URL + $hasValidFile = false; + foreach ($data[$fileType] as $fileInfo) { + if (isset($fileInfo['url']) && !empty($fileInfo['url'])) { + $hasValidFile = true; + break; + } + } + + if (!$hasValidFile) { + throw new BusinessException("创建备货单时{$fileLabel}文件不能为空"); + } + } + } + + /** + * 验证备货单审批时必需的文件是否存在 + * + * @param StockOrders $item 备货单对象 + * @throws BusinessException 当必需的文件缺失或为空时抛出异常 + */ + private function validateRequiredFilesForApproval($item): void + { + // 确保已加载文件关联 + if (!isset($item->files)) { + $item->load('files'); + } + + $requiredFiles = [ + \app\common\model\StockOrdersFiles::CONTRACT => '合同', + \app\common\model\StockOrdersFiles::TRANSACTION_SLIP => '水单', + \app\common\model\StockOrdersFiles::PI_DOC => 'PI' + ]; + + // 按文件类型分组现有文件 + $existingFiles = []; + foreach ($item->files as $file) { + if (!isset($existingFiles[$file->file_code])) { + $existingFiles[$file->file_code] = []; + } + $existingFiles[$file->file_code][] = $file; + } + + // 检查每种必需文件是否存在 + foreach ($requiredFiles as $fileCode => $fileLabel) { + if (!isset($existingFiles[$fileCode]) || empty($existingFiles[$fileCode])) { + throw new BusinessException("提交审批前必须上传{$fileLabel}文件"); + } + + // 检查文件中是否有有效的URL + $hasValidFile = false; + foreach ($existingFiles[$fileCode] as $file) { + if (!empty($file->file_url)) { + $hasValidFile = true; + break; + } + } + + if (!$hasValidFile) { + throw new BusinessException("提交审批前必须上传有效的{$fileLabel}文件"); + } + } + } + + protected function afterServiceStore($res) + { + // 处理原单 + if ($res->is_final == 1 && $res->object_id > 0) { + $order = StockOrders::find($res->object_id); + $order->is_use = 1; + $order->object_id = $res->id; + if (!$order->save()) { + flg('备货单【'.$order->order_no.'】创建终版成功,但更新原单失败'); + } + } + // 启动审批 + if ($res->approval_status == 1) { + $this->startApproval($res); + } + return $res; + } + + /** + * 启动审批 + */ + public function startApproval($item) + { + // 启动审批 + $appro = new \app\common\utils\approval\service\ApprovalFlowService(); + $formData = $this->checkNewOrUsedVehicles($item->id); + $appro->start('stock_orders', 'stock_orders', $item->id, $formData, $item->salesperson_id); + // 异步生成pdf文件 + \Webman\RedisQueue\Redis::send('stock-order-pdf', ['id'=>$item->id]); + } + + /** + * 创建PDF文件 + */ + public function createdPdfFile($id) + { + $config = []; + $config['model'] = $this->modelClass; + $config['where']['id'] = $id; + $config['with'][] = 'items'; + $config['with'][] = 'items.options'; + $config['with'][] = 'items.moneyValues'; + $config['with'][] = 'accounts'; + $config['with'][] = 'moneyValues'; + $config['with'][] = 'salesperson'; + $item = $this->logicShow($config); + $pdfData = $this->formatData($item); + + // 渲染模板为最终HTML + $tplPath = app_path('common/utils/pdf/template/stockOrder.stub'); + $html = \app\common\utils\pdf\service\PdfHelper::render($tplPath, ['data' => $pdfData]); + + // 生成PDF + $driver = new \app\common\utils\pdf\service\MpdfDriver(); + $pdfBin = $driver->htmlToPdf($html); + + // 存储 + $dir = public_path('pdf').'/'; + $fileName = ($pdfData['order_no'] ?? date('YmdHis')) . '.pdf'; + + // 本地保存 + \app\common\utils\pdf\service\PdfStorage::saveBinary( + $pdfBin, + $dir, + $fileName + ); + + // 上传云端 + $param = [ + 'path' => $dir . $fileName, + 'scene' => 'stock_orders', + 'category' => 'pdf', + 'original_name' => $fileName + ]; + $up = new \app\common\service\UploadService(); + $res = $up->uploadLocalFile($param); + + @unlink($dir . $fileName); + + // 更新文件地址 + $item->file_url = $res['file_name']; + $item->save(); + + return $res; + } + + + /** + * 查询审批 + */ + public function queryAppro($bizId) + { + $instances = ApprovalInstances::where('biz_id', $bizId)->where('biz_type', 'stock_orders')->where('status', 'approved')->first(); + if (!$instances) { + return []; + } + + $records = ApprovalRecords::where('instance_id', $instances->id)->where('action', 'approve')->orderBy('step_index', 'asc')->pluck('approver_id')->toArray(); + if (count($records) == 0) { + return []; + } + + $signImgs = []; + foreach ($records as $k => $v) { + $signImg = User::where('id', $v)->value('sign_img'); + $signImgs[$k] = (!empty($signImg))?(new \app\common\service\OssService)->generatePrivateUrl($signImg):''; + } + + return $signImgs; + } + + /** + * 打印数据 + */ + public function servicePrint(Request $request) + { + $config = $this->buildQueryConfig($request, 'show'); + $item = $this->logicShow($config); + return $this->postProcess('Print', $item); + } + + public function serviceDetail(Request $request) + { + $config = $this->buildQueryConfig($request, 'detail'); + $item = $this->logicShow($config); + $detail = $this->formatData($item); + return $this->postProcess('Detail', $detail); + } + + /** + * 审批 + */ + public function serviceApproval(Request $request) + { + $config = $this->buildQueryConfig($request, 'approval'); + $item = $this->logicShow($config); + + // 检查备货单是否包含必需的文件 + $this->validateRequiredFilesForApproval($item); + + $this->logicUpdateBy($config, [ + 'approval_status' => 1 + ]); + $this->startApproval($item); + return $this->postProcess('Approval', $item); + } + + /** + * 作废审批 + */ + public function serviceApprovalNullify(Request $request) + { + $config = $this->buildQueryConfig($request, 'approval'); + $item = $this->logicShow($config); + $this->approvalNullify($item); + return $this->postProcess('Approval', $item); + } + + /** + * 审批作废 + */ + public function approvalNullify($item) + { + $appro = new \app\common\utils\approval\service\ApprovalFlowService(); + $formData = $this->checkNewOrUsedVehicles($item->id); + $appro->startNullify('stock_orders_nullify', 'stock_orders', $item->id, $formData, $item->salesperson_id); + } + + /** + * 撤销审批 + */ + public function serviceApprovalRevoke(Request $request) + { + $config = $this->buildQueryConfig($request, 'approval'); + $item = $this->logicShow($config); + $this->approvalRevoke($item); + return $this->postProcess('Approval', $item); + } + + public function approvalRevoke($item) + { + $instance = \app\common\model\ApprovalInstances::where('biz_id', $item->id)->where('flow_type', 'stock_orders')->first(); + $appro = new \app\common\utils\approval\service\ApprovalFlowService(); + $appro->revoke($instance->id, $item->salesperson_id); + } + + /** + * 创建 + * + * @param Request $request + * @param array $data + * @return mixed + */ + public function serviceStore(Request $request, array $data = []) + { + // 关联表较多,增加事务处理 + return Db::transaction(function () use ($request, $data) { + $data = $this->preProcess('Store', $data, $request); + $dl = $this->getDataLimitFromRequest($request); + $created = $this->logicCreate($data, $dl); + // 添加金额 + $created->addMoney('cif_total', $data['cif_total']['currency'], $data['cif_total']['amount'], $data['cif_total']['rate'], '合计到岸价'); + // 处理车辆和账户信息 + $this->processOrderItems($created, $data); + // 处理文件附件 + $this->processOrderFiles($created, $data); + return $this->postProcess('Store', $created); + }); + } + + protected function onlyFillableData(Model|string $model, array $data, array $extraWhitelist = []): array + { + $m = \is_string($model) ? new $model : $model; + $keys = $m->getFillable(); + if (empty($keys)) { + $keys = $m->getConnection() + ->getSchemaBuilder() + ->getColumnListing($m->getTable()); + } + if ($extraWhitelist) { + $keys = array_merge($keys, $extraWhitelist); + } + return array_intersect_key($data, array_flip($keys)); + } + + /** + * 更新前处理 + * + * @param array $data + * @param Request $request + * @return array + */ + public function beforeServiceUpdate($data, Request $request) + { + // 检查必填文件是否已上传 + // $this->validateRequiredFiles($data); + + $data['biz_date'] = strtotime($data['biz_date']); + $data['eta_ship_date'] = strtotime($data['eta_ship_date']); + $data['latest_arrival_date'] = strtotime($data['latest_arrival_date']); + $data['approval_status'] = $data['approval_status'] == 1 ? 1 : 0; + + $data['pick_up_date'] = $data['pick_up_date'] ? strtotime($data['pick_up_date']) : 0; + $data['collect_all_date'] = $data['collect_all_date'] ? strtotime($data['collect_all_date']) : 0; + return $data; + } + + /** + * 更新后处理 + * + * @param mixed $res + * @return mixed + */ + protected function afterServiceUpdate($res) + { + // 启动审批 + if ($res->approval_status == 1) { + $this->startApproval($res); + } + return $res; + } + + /** + * 更新 + * + * @param Request $request + * @param array $data + * @return mixed + */ + public function serviceUpdate(Request $request, array $data = []) + { + // 关联表较多,增加事务处理 + return Db::transaction(function () use ($request, $data) { + $data = $this->preProcess('Update', $data, $request); + $config = $this->buildQueryConfig($request, 'update'); + // 过滤掉非库字段 + $dbData = $this->onlyFillableData($config['model'], $data); + // 更新主表 + $this->logicUpdateBy($config, $dbData); + // 获取更新后的数据 + $order = $this->logicShow($config); + // 处理车辆和账户信息 + $this->processOrderItems($order, $data, true); + // 添加金额 + $order->addMoney('cif_total', $data['cif_total']['currency'], $data['cif_total']['amount'], $data['cif_total']['rate'], '合计到岸价'); + // 处理文件附件 + $this->processOrderFiles($order, $data, true); + return $this->postProcess('Update', $order); + }); + } + + /** + * 处理订单项(车辆和账户) + * @param StockOrders $order 订单对象 + * @param array $data 数据 + * @param bool $isUpdate 是否为更新操作 + */ + private function processOrderItems($order, array $data, bool $isUpdate = false): void + { + // 如果是更新操作,先删除原有的关联数据 + if ($isUpdate) { + $this->clearExistingOrderItems($order->id); + } + // 处理车辆项 + $this->processVehicleItems($order, $data['veh_list'] ?? []); + // 处理账户项 + $this->processAccountItems($order, $data['account_list'] ?? []); + } + + /** + * 处理订单文件附件 + * @param StockOrders $order 订单对象 + * @param array $data 数据 + * @param bool $isUpdate 是否为更新操作 + */ + private function processOrderFiles($order, array $data, bool $isUpdate = false): void + { + // 如果是更新操作,先删除原有的文件附件 + if ($isUpdate) { + $this->clearExistingOrderFiles($order->id); + } + + // 处理文件附件项 + $this->processFileItems($order, $data); + } + + /** + * (更新时)清除已存在的订单文件附件 + * @param int $orderId 订单ID + */ + private function clearExistingOrderFiles(int $orderId): void + { + StockOrdersFiles::where('order_id', $orderId)->delete(); + } + + /** + * 处理文件附件项 + */ + private function processFileItems($order, array $data): void + { + // 定义支持的文件类型 + $fileTypes = [ + 'contract' => StockOrdersFiles::CONTRACT, + 'transaction_slip' => StockOrdersFiles::TRANSACTION_SLIP, + 'pi_doc' => StockOrdersFiles::PI_DOC + ]; + + // 遍历每种文件类型 + foreach ($fileTypes as $field => $fileCode) { + // 检查数据中是否存在该字段且为数组 + if (!empty($data[$field]) && is_array($data[$field])) { + // 处理该字段下的每个文件 + foreach ($data[$field] as $fileInfo) { + $fileData = [ + 'order_id' => $order->id, + 'file_code' => $fileCode, + 'file_url' => $fileInfo['url'] ?? '', + 'file_name' => StockOrdersFiles::FILE_CODE[$fileCode], + 'original_name' => $fileInfo['original_name'] ?? StockOrdersFiles::FILE_CODE[$fileCode], + ]; + + // 只有当file_url不为空时才创建记录 + if (!empty($fileData['file_url'])) { + StockOrdersFiles::create($fileData); + } + } + } + } + } + + /** + * (更新时)清除已存在的订单项 + * @param int $orderId 订单ID + */ + private function clearExistingOrderItems(int $orderId): void + { + // 删除车辆改装项 + StockOrderItemOptions::whereHas('item', function ($query) use ($orderId) { + $query->where('order_id', $orderId); + })->delete(); + + // 删除车辆项及其相关金额记录 + $orderItems = StockOrderItems::where('order_id', $orderId)->get(); + foreach ($orderItems as $item) { + // 删除车辆项的金额记录 + Db::table('veh_money_value') + ->where('object_scene', 'stock_order_items') + ->where('object_id', $item->id) + ->delete(); + } + + // 删除车辆项 + StockOrderItems::where('order_id', $orderId)->delete(); + + // 删除订单本身的金额记录 + Db::table('veh_money_value') + ->where('object_scene', 'stock_orders') + ->where('object_id', $orderId) + ->delete(); + + // 删除账户项 + StockOrderAccounts::where('order_id', $orderId)->delete(); + } + + /** + * 处理车辆项 + */ + private function processVehicleItems($order, array $vehList): void + { + $profitTotalAmount = 0.00; + $profitCurrency = ''; + $profitRate = 0; + foreach ($vehList as $veh) { + $veh['order_id'] = $order->id; + $veh['unique_id'] = $this->generateVehicleUniqueId($veh); + + $item = StockOrderItems::create($veh); + + // 处理金额信息 + $this->processVehiclePrices($item, $veh); + + // 累计到岸价 + $profitCurrency === '' ? $profitCurrency = $veh['profit']['currency'] ?? '' : ''; + $profitRate === 0 ? $profitRate = $veh['profit']['rate'] ?? 0 : ''; + $profitTotalAmount += $veh['profit']['amount'] ?? 0; + + // 处理改装项 + $this->processModificationItems($item->id, $veh['modify_list'] ?? []); + } + // 利润 + $order->addMoney('profit_total', $profitCurrency, $profitTotalAmount, $profitRate, '总利润'); + } + + /** + * 生成车辆唯一标识 + */ + private function generateVehicleUniqueId(array $veh): string + { + $keysToRemove = ['remark', 'modify_list', 'order_id']; + $array = array_diff_key($veh, array_flip($keysToRemove)); + $uniqueIdStr = json_encode($array); + return md5($uniqueIdStr); + } + + /** + * 处理车辆价格信息 + */ + private function processVehiclePrices($item, array $veh): void + { + // unit_price 单价/成交价/收款价 + // total_price 总价 + // handling_fee 手续费 + // fob_transport_fee FOB运输费 + // port_misc_fee 港杂 + // retrofit_fee 改装费 + // ocean_freight 海运费 + // commission 佣金 + // purchase_tax 购置税 + // tax_refund 预估退税 + // clearance_price 报关价 + // profit 利润 + // official_price 官方指导价 + // discount_price 优惠价 + // listing_price 二手挂牌价 + // reduction_price 二手减价/优惠价 + // retention_price 留款/挂账 + // car_price 车价 + // accessory_price 配件费 + // increase_price 加价 + + // 基础费用 + $priceFields = [ + 'unit_price' => '收款价', + 'total_price' => '总价', + 'handling_fee' => '手续费', + 'fob_transport_fee' => 'FOB运输费', + 'port_misc_fee' => '港杂', + 'retrofit_fee' => '改装费', + 'ocean_freight' => '海运费', + 'commission' => '佣金', + 'purchase_tax' => '购置税', + 'tax_refund' => '预估退税', + 'clearance_price' => '报关价', + 'profit' => '利润', + 'retention_price' => '挂账', + 'car_price' => '车价', + 'accessory_price' => '配件费', + 'increase_price' => '加价' + ]; + + foreach ($priceFields as $field => $description) { + $item->addMoney( + $field, + $veh[$field]['currency'], + (float)$veh[$field]['amount'], + $veh[$field]['rate'], + $description + ); + } + + // 根据里程处理新车或二手车价格 + if (!empty($veh['mileage']) && $veh['mileage'] > 0) { + // 二手车 + $item->addMoney( + 'listing_price', + $veh['listing_price']['currency'], + (float)$veh['listing_price']['amount'], + $veh['listing_price']['rate'], + '二手挂牌价' + ); + $item->addMoney( + 'reduction_price', + $veh['reduction_price']['currency'], + (float)$veh['reduction_price']['amount'], + $veh['reduction_price']['rate'], + '二手优惠价' + ); + } else { + // 新车 + $item->addMoney( + 'official_price', + $veh['official_price']['currency'], + (float)$veh['official_price']['amount'], + $veh['official_price']['rate'], + '官方指导价' + ); + $item->addMoney( + 'discount_price', + $veh['discount_price']['currency'], + (float)$veh['discount_price']['amount'], + $veh['discount_price']['rate'], + '优惠价' + ); + } + } + + /** + * 处理改装项 + */ + private function processModificationItems(int $orderItemId, array $modifyList): void + { + if (empty($modifyList)) { + return; + } + + foreach ($modifyList as $modify) { + $modify['order_item_id'] = $orderItemId; + StockOrderItemOptions::create($modify); + } + } + + /** + * 处理账户项 + */ + private function processAccountItems($order, array $accountList): void + { + if (empty($accountList)) { + return; + } + + foreach ($accountList as $account) { + $account['order_id'] = $order->id; + if (!empty($account['remit_date'])) { + $account['remit_date'] = strtotime($account['remit_date']); + } + StockOrderAccounts::create($account); + } + } + + + // ====== ======= ====== ======= ====== 信息处理 ======= ====== ======= ====== ======= + + // PDF数据 + public function formatData($item) + { + // 提取车辆列表信息 + $vehListData = $this->prepareVehicleListData($item); + + // 提取订单金额信息 + $moneyValues = $this->prepareOrderMoneyValues($item); + + // 提取账户列表信息 + $accountList = $this->prepareAccountList($item); + + // 提取文件附件列表信息 + $fileList = $this->prepareFileList($item); + + // 构建返回数据 + $veh = [ + // 基本信息 + 'order_no' => $item->order_no, + 'salesperson_snapshot' => $item->salesperson_snapshot, + 'sign_img_url' => $this->getSignatureImageUrl($item->salesperson->sign_img ?? null), + 'created_at' => date('Y-m-d', strtotime($item->created_at)), + + 'pi_no' => $item->pi_no, + 'biz_date' => date('Y-m-d', $item->biz_date), + 'clearance_country_snapshot' => $item->clearance_country_snapshot, + 'customer_source_snapshot' => $item->customer_source_snapshot, + 'customer_snapshot' => $item->customer_snapshot, + 'customer_country_snapshot' => $item->customer_country_snapshot, + + // 订单项 + 'veh_list' => array_values($vehListData['list']), + 'contract_exchange_rate' => $item->contract_exchange_rate, + 'profit_total' => $moneyValues['profit_total'] ?? [], + 'cif_total' => $moneyValues['cif_total'] ?? [], + + // 运输和交付信息 + 'declarant_name' => $item->declarant_name, + 'eta_ship_date' => date('Y-m-d', $item->eta_ship_date), + 'shipping_method_snapshot' => $item->shipping_method_snapshot, + 'domestic_port_snapshot' => $item->domestic_port_snapshot, + 'trade_term_snapshot' => $item->trade_term_snapshot, + 'clearance_port_snapshot' => $item->clearance_port_snapshot, + 'latest_arrival_date' => date('Y-m-d', $item->latest_arrival_date), + 'pick_up_date' => date('Y-m-d', $item->pick_up_date), + 'collect_all_date' => date('Y-m-d', $item->collect_all_date), + 'is_final' => $item->is_final, + + // 账户信息 + 'account_list' => $accountList, + 'acc_index' => count($accountList) + 1, + 'currency_symbol' => $item->settlement_currency, + 'sign_imgs' => $this->queryAppro($item->id), + 'contract_url' => $this->getContractUrl($item->contract ?? null), + + // 文件附件信息 + 'file_list' => $fileList + ]; + + return $veh; + } + + /** + * 准备车辆列表数据 + */ + private function prepareVehicleListData($item): array + { + $vehList = []; + $index = 0; + foreach ($item->items as $v) { + // 如果已存在相同车辆,增加数量 + if (isset($vehList[$v->unique_id])) { + $this->incrementVehicleCount($vehList[$v->unique_id]); + // 拼接 remark 值 + if (!empty($v->remark)) { + if (!empty($vehList[$v->unique_id]['remark'])) { + $vehList[$v->unique_id]['remark'] .= "
" . $v->remark; + } else { + $vehList[$v->unique_id]['remark'] = $v->remark; + } + } + continue; + } + $index++; + // 处理金额信息 + $moneyValues = $this->processItemMoneyValues($v); + // 构建车辆信息 + $vehList[$v->unique_id] = [ + 'index' => $index, + 'model_snapshot' => $v->model_snapshot, + 'color_interior_snapshot' => $v->color_interior_snapshot, + 'color_exterior_snapshot' => $v->color_exterior_snapshot, + 'num' => 1, + 'mileage' => $v->mileage, + 'remark' => $v->remark, + 'vin' => $v->vin, + ] + $moneyValues; + } + return ['list' => $vehList]; + } + + /** + * 处理单项金额值 + */ + private function processItemMoneyValues($item): array + { + $moneyValues = []; + foreach ($item->moneyValues as $money) { + $moneyValues[$money->field] = [ + 'currency' => $money->currency, + 'amount' => $money->amount, + 'rate' => $money->exchange_rate, + 'currency_info' => $this->formatCurrency($money->amount, $money->currency) + ]; + } + return $moneyValues; + } + + /** + * 获取改装文本 + */ + private function getModificationText($options): string + { + $modificationText = ''; + foreach ($options as $option) { + $modificationText .= $option->option_name_snapshot . ' '; + } + return trim($modificationText); + } + + /** + * 增加车辆数量并更新相关金额显示 + */ + private function incrementVehicleCount(array &$vehicleData): void + { + $vehicleData['num'] += 1; + + // ** 11.11 remove 价格*数量 + // $fieldList = [ + // 'fob_transport_fee', + // 'car_price', + // 'ocean_freight', + // 'commission', + // 'retention_price', + // 'port_misc_fee', + // 'total_price', + // 'clearance_price', + // 'profit' + // ]; + + // foreach ($vehicleData as $field => $value) { + // if (in_array($field, $fieldList)) { + // $amount = $vehicleData[$field]['amount'] * $vehicleData['num']; + // $currencySymbol = SysCurrency::where('currency_code', $vehicleData[$field]['currency'])->value('currency_symbol'); + // $vehicleData[$field]['currency_info'] = $currencySymbol . number_format($amount, 2, '.', ','); + // } + // } + } + + /** + * 准备订单金额值 + */ + private function prepareOrderMoneyValues($item): array + { + $moneyValues = []; + foreach ($item->moneyValues as $money) { + $moneyValues[$money->field] = [ + 'currency' => $money->currency, + 'amount' => $money->amount, + 'rate' => $money->exchange_rate, + 'currency_info' => $this->formatCurrency($money->amount, $money->currency) + ]; + } + return $moneyValues; + } + + /** + * 准备账户列表 + */ + private function prepareAccountList($item): array + { + $accountList = []; + foreach ($item->accounts as $k => $account) { + $accountList[] = [ + 'index' => '收款' . ($k + 1), + 'remit_country_name_snapshot' => $account->remit_country_name_snapshot, + 'remit_date' => date('Y-m-d', $account->remit_date), + 'currency_name_snapshot' => $account->currency_name_snapshot, + 'account_name_snapshot' => $account->account_name_snapshot, + 'remitter_name' => $account->remitter_name, + 'amount' => $account->amount, + ]; + } + return $accountList; + } + + /** + * 准备文件附件列表 + */ + private function prepareFileList($item): array + { + $fileList = []; + + // 按file_code分组文件 + $groupedFiles = []; + foreach ($item->files as $file) { + if (!isset($groupedFiles[$file->file_code])) { + $groupedFiles[$file->file_code] = []; + } + $groupedFiles[$file->file_code][] = [ + 'id' => $file->id, + 'file_url' => $file->file_url, + 'file_full_url' => $this->getFileUrl($file->file_url), + 'original_name' => $file->original_name, + ]; + } + + // 定义支持的文件类型 + $fileTypes = [ + 'contract' => StockOrdersFiles::CONTRACT, + 'transaction_slip' => StockOrdersFiles::TRANSACTION_SLIP, + 'pi_doc' => StockOrdersFiles::PI_DOC + ]; + + // 按前端需要的字段组织数据 + foreach ($fileTypes as $field => $fileCode) { + if (isset($groupedFiles[$fileCode])) { + $fileList[$field] = $groupedFiles[$fileCode]; + } else { + $fileList[$field] = []; + } + } + + return $fileList; + } + + /** + * 获取货币信息 + */ + private function getCurrencyInfo(string $currencyCode): array + { + return SysCurrency::where('currency_code', $currencyCode) + ->first(['currency_symbol', 'currency_name']) + ->toArray(); + } + + /** + * 格式化货币显示 + */ + private function formatCurrency(float $amount, string $currencyCode): string + { + $currencySymbol = SysCurrency::where('currency_code', $currencyCode)->value('currency_symbol'); + return $currencySymbol . number_format($amount, 2, '.', ','); + } + + /** + * 获取签名图片URL + */ + private function getSignatureImageUrl(?string $signImg): string + { + if (empty($signImg)) { + return ''; + } + return (new \app\common\service\OssService)->generatePrivateUrl($signImg); + } + + /** + * 获取合同URL + */ + private function getContractUrl(?string $contract): string + { + if (empty($contract)) { + return ''; + } + return (new \app\common\service\OssService)->generatePrivateUrl($contract); + } + + /** + * 获取文件URL + */ + private function getFileUrl(?string $fileUrl): string + { + if (empty($fileUrl)) { + return ''; + } + return (new \app\common\service\OssService)->generatePrivateUrl($fileUrl); + } + + /** + * 检查备货单中的车辆是新车还是二手车 + * @param int $stockOrderId 备货单ID + * @return array 返回包含has_new和has_used的数组 + */ + public function checkNewOrUsedVehicles(int $stockOrderId): array + { + // 获取备货单关联的所有车辆项 + $vehicles = StockOrderItems::where('order_id', $stockOrderId)->get(); + + $hasNew = false; + $hasUsed = false; + $submitNote = ''; + + foreach ($vehicles as $vehicle) { + // 判断是否为二手车(mileage > 0) + if ($vehicle->mileage > 0) { + $hasUsed = true; + $submitNote = '本单只有二手车'; + } else { + $hasNew = true; + $submitNote = '本单只有新车'; + } + + // 如果两种类型都已经存在,可以提前退出循环 + if ($hasNew && $hasUsed) { + $submitNote = '本单包含新车和二手车'; + break; + } + } + + return [ + 'has_new' => $hasNew ? 1 : 0, + 'has_used' => $hasUsed ? 1 : 0, + 'submit_note' => $submitNote + ]; + } + + + public function serviceDestroy(Request $request, string $scene = 'destroy') + { + $config = $this->buildQueryConfig($request, $scene); + $item = $this->logicShow($config); + if (!$item) { + throw new BusinessException('数据不存在'); + } + if ($item->approval_status == 1 || $item->approval_status == 2) { + throw new BusinessException('审核中或者已审核的订单不能删除'); + } + $deleted = $this->logicDeleteBy($config); + return $this->postProcess(ucfirst($scene), $deleted); + } +} \ No newline at end of file diff --git a/app/common/service/SysAccountService.php b/app/common/service/SysAccountService.php new file mode 100644 index 0000000..b9c59ad --- /dev/null +++ b/app/common/service/SysAccountService.php @@ -0,0 +1,28 @@ +logic = (new SysAccountLogic()); + } + + /** + * 获取系统list + */ + public function getList() + { + return $this->logic->getList(); + } +} \ No newline at end of file diff --git a/app/common/service/SysConfigService.php b/app/common/service/SysConfigService.php new file mode 100644 index 0000000..d52972f --- /dev/null +++ b/app/common/service/SysConfigService.php @@ -0,0 +1,37 @@ +logic = (new SysConfigLogic()); + } + + /** + * 获取系统list + */ + public static function getConfig(string $config_key) + { + $value = Redis::get($config_key); + if (empty($value)) { + $config = (new SysConfigLogic())->findByKey($config_key); + if (empty($config)) { + return null; + } + $value = $config->config_value; + Redis::set($config->config_key, $value, 'EX', 7200); + } + + return $value; + } +} \ No newline at end of file diff --git a/app/common/service/SysCurrencyService.php b/app/common/service/SysCurrencyService.php new file mode 100644 index 0000000..f0108d0 --- /dev/null +++ b/app/common/service/SysCurrencyService.php @@ -0,0 +1,25 @@ +logic = (new SysCurrencyLogic()); + } + + /** + * 获取系统list + */ + public function getList() + { + return $this->logic->getList(); + } +} \ No newline at end of file diff --git a/app/common/service/SysDictItemService.php b/app/common/service/SysDictItemService.php new file mode 100644 index 0000000..c1e51ed --- /dev/null +++ b/app/common/service/SysDictItemService.php @@ -0,0 +1,42 @@ +logic = (new SysDictItemLogic()); + } + + /** + * 获取系统list + */ + public function getList($params) + { + if (empty($params['dict_id']) && empty($params['dict_code'])) + throw new BusinessException('未明确要查询的字典组'); + $where = []; + if(isset($params['dict_id']) && $params['dict_id']) + $where[] = ['dict_id', '=', $params['dict_id']]; + if(isset($params['dict_code']) && $params['dict_code']){ + $dict = (new SysDictLogic)->getDictByCode($params['dict_code']); + if (empty($dict['id'])) + throw new BusinessException('字典组code错误'); + $where[] = ['dict_id', '=', $dict['id']]; + } + + return $this->logic->getList($where); + } +} \ No newline at end of file diff --git a/app/common/service/SysDictService.php b/app/common/service/SysDictService.php new file mode 100644 index 0000000..4918017 --- /dev/null +++ b/app/common/service/SysDictService.php @@ -0,0 +1,52 @@ +logic = (new SysDictLogic()); + } + + /** + * 获取系统list + */ + public function getList() + { + return $this->logic->getList(); + } + + + + public static function pluckValuesByCode(string $dictCode): array + { + return SysDict::where('dict_code', $dictCode) + ->where('status', 1) + ->first()?->items() + ->where('status', 1) + ->orderBy('sort') + ->pluck('item_value') + ->toArray() ?? []; + } + + public static function pluckKeyValueByCode(string $dictCode): array + { + return SysDict::where('dict_code', $dictCode) + ->where('status', 1) + ->first()?->items() + ->where('status', 1) + ->orderBy('sort') + ->pluck('item_value', 'item_label') + ->toArray() ?? []; + } +} \ No newline at end of file diff --git a/app/common/service/SysOpLogService.php b/app/common/service/SysOpLogService.php new file mode 100644 index 0000000..ee19817 --- /dev/null +++ b/app/common/service/SysOpLogService.php @@ -0,0 +1,78 @@ +validator; + } + + + /** + * 构建查询条件 + */ + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'index': + // ====== ====== 排序 ====== ====== + $config['order']['id'] = 'desc'; + + // ====== ====== 筛选 ====== ====== + // 状态 + if (isset($p['user_id']) && $p['user_id']) { + $config['where']['user_id'] = $p['user_id']; + } + // ip + if (isset($p['ip']) && $p['ip']) { + $config['where']['ip'] = $p['ip']; + } + // ip + if (isset($p['method']) && $p['method']) { + $config['where']['method'] = $p['method']; + } + // 关键字 + if (isset($p['keyword']) && $p['keyword']) { + $k = '%' . $p['keyword'] . '%'; + foreach (['user_name', 'route', 'params_brief'] as $col) { + $config['orWhere'][] = [$col, 'like', $k]; + } + } + //$config['where']['status'] = 1; + break; + case 'show': + $config['where']['id'] = $p['id']; + //$config['where']['status'] = 1; + break; + } + + return $config; + } +} \ No newline at end of file diff --git a/app/common/service/SysPortService.php b/app/common/service/SysPortService.php new file mode 100644 index 0000000..5235638 --- /dev/null +++ b/app/common/service/SysPortService.php @@ -0,0 +1,34 @@ +logic = (new SysPortLogic()); + } + + /** + * 获取系统list + */ + public function getList($params) + { + $where = []; + if(isset($params['scene']) && $params['scene']) { + if($params['scene'] == 'domestic'){ + $where['country_id'] = 45; + } + } + return $this->logic->getList($where); + } +} \ No newline at end of file diff --git a/app/common/service/SysUpdateLogService.php b/app/common/service/SysUpdateLogService.php new file mode 100644 index 0000000..c93c350 --- /dev/null +++ b/app/common/service/SysUpdateLogService.php @@ -0,0 +1,77 @@ +validator; + } + + /** + * 构造 CrudUtil 查询配置 + */ + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'index': + $config['order']['id'] = 'desc'; + break; + case 'show': + $config['where']['id'] = $p['id']; + break; + case 'store': + break; + case 'update': + case 'destroy': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } + + /** + * 添加前置处理 + */ + public function beforeServiceStore($data, Request $request) + { + $data['update_time'] = !empty($data['update_time'])?strtotime($data['update_time']):0; + return $data; + } + + /** + * 添加前置处理 + */ + public function beforeServiceupdate($data, Request $request) + { + $data['update_time'] = !empty($data['update_time'])?strtotime($data['update_time']):0; + return $data; + } +} \ No newline at end of file diff --git a/app/common/service/SystemsService.php b/app/common/service/SystemsService.php new file mode 100644 index 0000000..10b80a3 --- /dev/null +++ b/app/common/service/SystemsService.php @@ -0,0 +1,24 @@ +logic = (new SystemsLogic()); + } + + /** + * 获取系统list + */ + public function getList() + { + return $this->logic->getList(); + } +} \ No newline at end of file diff --git a/app/common/service/UploadService.php b/app/common/service/UploadService.php new file mode 100644 index 0000000..62b838c --- /dev/null +++ b/app/common/service/UploadService.php @@ -0,0 +1,177 @@ +findFile(['file_path' => $candidatePath]); + if (!$existing) { + break; + } + // 若存在同路径同名,且类型也相同,则追加序号 + if (isset($existing->file_type) && strtolower($existing->file_type) === strtolower($mime)) { + $suffix = ' (' . $counter . ')'; + $candidateName = $nameBody . $suffix . ($nameExt !== '' ? ('.' . $nameExt) : ''); + $counter++; + continue; + } + // 类型不同则允许同名,结束循环 + break; + } + + $objectKey = $dir . $candidateName; + + $ossService = new OssService(); + $ossService->uploadImage($filePath, $objectKey); + $privateUrl = $ossService->generatePrivateUrl($objectKey); + + // 记录文件信息到数据库 + $uploadedFilesService = new UploadedFilesService(); + $fileInfo = [ + 'file_name' => basename($objectKey), + 'file_path' => $objectKey, + 'file_size' => $size, + 'file_type' => $mime, + 'original_name' => $originalName, + ]; + $recordResult = $uploadedFilesService->recordFileInfo($fileInfo, null, $scene, $category); + + $data = [ + 'original_name' => $originalName, + 'file_name' => $objectKey, + 'file_url' => $privateUrl, + 'file_id' => $recordResult['id'] ?? null, + 'partition' => $scene, + 'category' => $category, + ]; + + return $data; + } catch (\Throwable $e) { + Log::error($e->getMessage(),$param); + throw new BusinessException($e->getMessage()); + } + } + + /** + * 删除 OSS 上的文件 + * + * @param array $param + * - object_key: OSS 对象键名-oss路径(必需) + * - file_id: 数据库文件记录ID(可选,用于删除数据库记录) + * @return bool 删除是否成功 + * + * 示例 + * $uploadService = new UploadService(); + * + * 只删除 OSS 文件 + * $uploadService->deleteOssFile([ + * 'object_key' => 'vehicle_album/documents/20241201/abc123.pdf' + * ]); + * + * 同时删除 OSS 文件和数据库记录 + * $uploadService->deleteOssFile([ + * 'object_key' => 'vehicle_album/images/20241201/abc123.jpg', + * 'file_id' => 123 + * ]); + */ + public function deleteOssFile($objectKey,$fileId = null) + { + try { + + if (empty($objectKey)) { + Log::error('OSS对象键名不能为空', $objectKey); + throw new BusinessException('OSS对象键名不能为空'); + } + + $uploadedFilesService = new UploadedFilesService(); + // 如果提供了文件ID,同时删除数据库记录 + if ($fileId) { + $uploadedFilesService->deleteFile($fileId); + }else{ + $uploadedFilesService = new UploadedFilesService(); + if (empty($fileId)) { + $where = ['file_path' => $objectKey]; + $file = $uploadedFilesService->getFile($where); + $fileId = $file->id; + } + if(!empty($fileId)){ + $uploadedFilesService->deleteFile($fileId); + } + } + + // 删除 OSS 上的文件 + $ossService = new OssService(); + $ossService->deleteFile($objectKey); + + Log::info('OSS文件删除成功', ['object_key' => $objectKey, 'file_id' => $fileId]); + return true; + } catch (\Throwable $e) { + Log::error('删除OSS文件失败: ' . $e->getMessage()); + throw new BusinessException('删除OSS文件失败: ' . $e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/common/service/UploadedFilesService.php b/app/common/service/UploadedFilesService.php new file mode 100644 index 0000000..ed01db2 --- /dev/null +++ b/app/common/service/UploadedFilesService.php @@ -0,0 +1,238 @@ + [ + 'path' => 'images', + 'max_size' => 5 * 1024 * 1024, // 5MB + 'allowed_types' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], + 'extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp'] + ], + 'document' => [ + 'path' => 'documents', + 'max_size' => 10 * 1024 * 1024, // 10MB + 'allowed_types' => ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + 'extensions' => ['pdf', 'doc', 'docx'] + ], + 'audio' => [ + 'path' => 'audios', + 'max_size' => 20 * 1024 * 1024, // 20MB + 'allowed_types' => ['audio/mp3', 'audio/wav', 'audio/ogg'], + 'extensions' => ['mp3', 'wav', 'ogg'] + ], + 'other' => [ + 'path' => 'others', + 'max_size' => 5 * 1024 * 1024, // 5MB + 'allowed_types' => [], + 'extensions' => [] + ] + ]; + + /** + * 记录文件信息到数据库 + * + * @param array $fileInfo 文件信息 + * @param Request|null $request 请求对象(可选) + * @param string $partition 分区名称 + * @param string $category 文件分类 + * @return array + * @throws BusinessException + */ + public function recordFileInfo(array $fileInfo, ?Request $request = null, string $partition = 'other', string $category = 'general'): array + { + + // 验证必需的文件信息 + $requiredFields = ['file_name', 'file_path', 'file_size', 'file_type']; + foreach ($requiredFields as $field) { + if (!isset($fileInfo[$field])) { + throw new BusinessException("缺少必需的文件信息字段: {$field}"); + } + } + + // 获取文件扩展名 + $fileExtension = pathinfo($fileInfo['file_name'], PATHINFO_EXTENSION); + + // 记录到数据库 + $uploadIp = $request ? $request->getRealIp() : '0.0.0.0'; + $userAgent = $request ? $request->header('User-Agent', '') : ''; + + $fileData = [ + 'file_name' => $fileInfo['file_name'], + 'file_path' => $fileInfo['file_path'], + 'file_size' => $fileInfo['file_size'], + 'file_type' => $fileInfo['file_type'], + 'category' => $category, + 'original_name' => $fileInfo['original_name'] ?? '', + 'extension' => $fileExtension, + 'partition' => $partition, + 'upload_ip' => $uploadIp, + 'user_agent' => $userAgent, + ]; + + $uploadedFile = $this->logicCreate($fileData); + + return [ + 'id' => $uploadedFile->id, + 'file_name' => $fileInfo['file_name'], + 'file_path' => $fileInfo['file_path'], + 'file_url' => getenv('MAIN_DOMAIN') . $fileInfo['file_path'], + 'file_size' => $fileInfo['file_size'], + 'file_type' => $fileInfo['file_type'], + 'category' => $category, + 'partition' => $partition, + 'original_name' => $fileInfo['original_name'] ?? '', + 'extension' => $fileExtension, + ]; + } + + /** + * 获取文件信息 + * + * @param int $fileId + * @return array|null + */ + public function getFileInfo(int $fileId): ?array + { + $file = $this->logicShow(['where' => ['id' => $fileId]]); + + if (!$file) { + return null; + } + + return [ + 'id' => $file->id, + 'file_name' => $file->file_name, + 'file_path' => $file->file_path, + 'file_url' => getenv('MAIN_DOMAIN') . $file->file_path, + 'file_size' => $file->file_size, + 'file_type' => $file->file_type, + 'category' => $file->category, + 'partition' => $file->partition ?? 'other', + 'original_name' => $file->original_name ?? '', + 'extension' => $file->extension ?? '', + 'created_at' => $file->created_at, + ]; + } + + /** + * 获取文件 + * + * @param string $path + * @return bool|\Illuminate\Database\Eloquent\Model + */ + public function getFile(array $where) + { + $config['model'] = $this->modelClass; + $config['where'] = $where; + $file = $this->logicShow($config); + + if (!$file) { + return false; + } + + return $file; + } + /** + * 获取文件 + * + * @param string $path + * @return bool|\Illuminate\Database\Eloquent\Model + */ + public function findFile(array $where) + { + $file = UploadedFiles::where(['file_path' => $where['file_path']]); + + if (!$file) { + return false; + } + + return $file; + } + + /** + * 删除文件 + * + * @param int $fileId + * @return bool + */ + public function deleteFile(int $fileId): bool + { + $config['model'] = $this->modelClass; + $config['where'] = ['id' => $fileId]; + $file = $this->logicShow($config); + + if (!$file) { + return false; + } + + // 删除数据库记录 + $config =[ + 'model' => $this->modelClass, + 'where' => ['id' => $fileId], + ]; + return $this->logicDeleteBy($config); + } + + /** + * 获取分区配置 + * + * @return array + */ + public function getPartitionConfig(): array + { + return $this->uploadPartitions; + } + + /** + * 生成文件名 + * + * @param $file + * @return string + */ + protected function generateFileName($file): string + { + $extension = $file->getUploadExtension(); + $timestamp = time(); + $random = substr(md5(uniqid()), 0, 8); + + return $timestamp . '_' . $random . '.' . $extension; + } + + /** + * 格式化文件大小 + * + * @param int $bytes + * @return string + */ + protected function formatFileSize(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $bytes = max($bytes, 0); + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + + $bytes /= pow(1024, $pow); + + return round($bytes, 2) . ' ' . $units[$pow]; + } +} \ No newline at end of file diff --git a/app/common/service/UsedcarCrawlResultsImagesService.php b/app/common/service/UsedcarCrawlResultsImagesService.php new file mode 100644 index 0000000..b080fa2 --- /dev/null +++ b/app/common/service/UsedcarCrawlResultsImagesService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/UsedcarCrawlResultsService.php b/app/common/service/UsedcarCrawlResultsService.php new file mode 100644 index 0000000..5aa1cdd --- /dev/null +++ b/app/common/service/UsedcarCrawlResultsService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/UserGroupMembersService.php b/app/common/service/UserGroupMembersService.php new file mode 100644 index 0000000..ba55b21 --- /dev/null +++ b/app/common/service/UserGroupMembersService.php @@ -0,0 +1,33 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/UserGroupService.php b/app/common/service/UserGroupService.php new file mode 100644 index 0000000..998b2ba --- /dev/null +++ b/app/common/service/UserGroupService.php @@ -0,0 +1,124 @@ +validator; + } + + + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'default': + $config['where']['status'] = 1; + $config['order']['sort'] = 'desc'; + $config['order']['id'] = 'desc'; + + $config['with'] = ['groupMembers', 'groupMembers.user']; + break; + case 'index': + $config['where']['status'] = 1; + $config['order']['sort'] = 'desc'; + $config['order']['id'] = 'desc'; + + $config['with'] = ['groupMembers', 'groupMembers.user']; + break; + case 'show': + $config['where']['id'] = $p['id']; + $config['where']['status'] = 1; + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + case 'userGroup': + $config['where']['status'] = 1; + $config['order']['sort'] = 'desc'; + $config['order']['id'] = 'desc'; + $config['fields'] = ['id', 'title']; + break; + } + + return $config; + } + + public function serviceStore(Request $request, array $data = [], string $scene = 'store') + { + $data = $this->preProcess(ucfirst($scene), $data, $request); + $created = (new UserGroupLogic())->createGroup($data); + return $this->postProcess(ucfirst($scene), $created); + } + + public function serviceUpdate(Request $request, array $data = [], string $scene = 'update') + { + $data = $this->preProcess(ucfirst($scene), $data, $request); + $config = $this->buildQueryConfig($request, $scene); + $userGroup = $this->logicShow($config); + $updated = (new UserGroupLogic())->updateGroup($userGroup, $data); + return $this->postProcess(ucfirst($scene), $updated); + } + + public function serviceDestroy(Request $request, string $scene = 'destroy') + { + $config = $this->buildQueryConfig($request, $scene); + $deleted = $this->logicDeleteBy($config); + (new UserGroupMembersLogic())->deleteUserGroupMembers($config['where']['id']); + return $this->postProcess(ucfirst($scene), $deleted); + } + + + public function userGroup(Request $request, string $scene = 'userGroup') + { + $config = $this->buildQueryConfig($request, $scene); + $result = $this->logicListAll($config); + return $this->postProcess(ucfirst($scene), $result); + } + + public function userGroupScene(Request $request, string $scene = 'userGroup') + { + $data = (new \app\common\service\SysDictService())->pluckKeyValueByCode('user_group_scene'); + $result = []; + foreach($data as $key => $value) { + $result[] = [ + 'name' => $key, + 'id' => $value, + ]; + } + return $this->postProcess(ucfirst($scene), $result); + } +} \ No newline at end of file diff --git a/app/common/service/UserService.php b/app/common/service/UserService.php new file mode 100644 index 0000000..bb8ae75 --- /dev/null +++ b/app/common/service/UserService.php @@ -0,0 +1,253 @@ +logic = (new UserLogic()); + } + + /** + * 获取用户信息(包括菜单和权限) + */ + public function getUserInfo(int $uid, string $systemCode) + { + $user = $this->logic->userInfo($uid); + // $permService = new PermService(); + // $menuTree = $permService->getMenuTree($uid, $systemCode); + // $permissionCodes = $permService->getPermissionCodes($uid, $systemCode); + $corp_name = Corp::where('abbr', $user->corp_id)->value('name'); + return [ + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'avatar' => $user->avatar, + 'phone' => $user->phone, + 'email' => $user->email, + 'status' => $user->status, + 'roles' => $user->roles->pluck('name'), + 'sex' => $user->sex, + 'sn' => $user->sn, + 'corp_id' => $user->corp_id, + 'corp_name' => $corp_name, + 'report_to' => $user->report_to, + 'sign_img' => $user->sign_img, + 'sign_img_url' => (!empty($user->sign_img))?(new \app\common\service\OssService)->generatePrivateUrl($user->sign_img):'', + ], + // 'menu' => $menuTree, + // 'permissions' => $permissionCodes + ]; + } + + + /** + * 获取用户信息(包括菜单和权限) + */ + public function getUser(int $uid, $corp_abbr) + { + $user = $this->logic->userInfo($uid); + + // 查找直属上级 10.13 add + $reportToInfo = []; + if ($user->reportToInfo) { + $reportToInfo = [ + 'id' => $user->reportToInfo->id, + 'name' => $user->reportToInfo->name + ]; + } + + // 查找绑定内勤 10.13 add + $officeClerkInfo = []; + $ugm = UserGroupMembers::where('user_id', $uid)->where('status', 1)->first(); + if ($ugm) { + $lugm = UserGroupMembers::where('group_id', $ugm->group_id)->where('is_leader', 1)->first(); + if ($lugm) { + $officeClerkInfo['id'] = $lugm->user_id; + $officeClerkInfo['name'] = User::where('id', $lugm->user_id)->value('name'); + } + } + + $corp_name = Corp::where('abbr', $corp_abbr)->value('name'); + $ossservice = new OssService(); + return [ + 'id' => $user->id, + 'sn' => $user->sn, + 'name' => $user->name, + 'position' => $user->position, + 'socialize' => $user->socialize, + 'socialize_pic' => $user->socialize_pic, + 'socialize_pic_full' => (!empty($user->socialize_pic))?$ossservice->generatePrivateUrl($user->socialize_pic):'', + 'country_id' => $user->country_id, + 'country_txt' => Country::where('id',$user->country_id)->value('cn_addr'), + 'avatar' => $user->avatar, + 'avatar_full' => $user->avatar, + 'phone' => $user->phone, + 'corp_name' => $corp_name, + 'email' => $user->email, + 'sex' => $user->sex, + 'status' => $user->status, + 'roles' => $user->roles->pluck('name'), + 'sign_img' => $user->sign_img, + 'sign_img_url' => (!empty($user->sign_img))?$ossservice->generatePrivateUrl($user->sign_img):'', + 'report_to_info' => $reportToInfo, + 'office_clerk_info' => $officeClerkInfo + ]; + } + + /** + * 编辑用户信息 + */ + public function edit(int $uid, array $data) + { + unset($data['password']); + unset($data['phone']); + unset($data['email']); + unset($data['sn']); + + $user = $this->logic->edit($uid,$data); + return $user; + } + + /** + * 修改用户密码 + */ + public function savePassword(int $uid, array $data) + { + // 对密码进行哈希处理 + $password = $data['password']; + $user = User::find($uid); + if (!password_verify($password, $user->password)) { + throw new BusinessException('密码错误'); + } + + $user = $this->logic->savePassword($uid,$data); + return $user; + } + + /** + * 获取用户列表 + */ + public function getList(Request $request) + { + return $this->logic->getList($request); + } + + /** 分页列表 */ + public function serviceTeam(Request $request) + { + $config = $this->buildQueryConfig($request, 'team'); + $limit = (int)$request->get('size', 15); + $page = (int)$request->get('current', 1); + $result = $this->logicList($config, $limit, $page); + + foreach ($result as &$data){ + $data['customer_count'] = Customer::where('user_id', $data['id'])->count(); + unset($data['password']); + unset($data['open_id']); + unset($data['tenant_key']); + unset($data['union_id']); + } + + return $this->postProcess('List', $result); + } + + /** 分页列表 */ + public function resignMember(Request $request) + { + $uid = $request->user->uid; + $userId = $request->get('user_id'); + $user = User::find($userId); + if ($user->report_to != $uid) { + throw new BusinessException('禁止操作非组员'); + } + + $data = [ + 'status' => 2, + ]; + $user = $this->logic->edit($userId,$data); + $user = $user->toArray(); + unset($user['password']); + unset($user['open_id']); + unset($user['tenant_key']); + unset($user['union_id']); + return $user; + } + + /** + * 构造 CrudUtil 查询配置 + */ + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'team': +// $config['where']['status'] = 1; // 关键字 + $config['order']['id'] = 'desc'; + + if (isset($p['status']) && $p['status']) { + $config['where']['status'] = $p['status']; + } + + $config['with']= ['reportToInfo']; + if (!in_array(1, $request->user->role_ids)) { + $config['where']['reportToInfo.id'] = $request->user->uid; + } + + // $config['dataLimit']['type'] = 'relation'; + // $config['dataLimit']['userId'] = $request->user->uid; + // $config['dataLimit']['userField'] = 'report_to'; + break; + } + + return $config; + } + + + public function checkBind($userId) + { + $user = \app\common\model\User::find($userId); + if (!$user->report_to) { + throw new BusinessException('请先前往个人中心-绑定直属上级'); + } + + if ($user->sign_img === null || $user->sign_img === '') { + throw new BusinessException('请先前往个人中心-操作签名'); + } + + $ug = \app\common\model\UserGroupMembers::with(['group' => function($query) { + $query->where('scenario_code', 'stock_orders'); + }])->where('user_id', $userId)->first(); + if (!$ug) { + throw new BusinessException('请先前往个人中心-绑定内勤'); + } + + return true; + } +} \ No newline at end of file diff --git a/app/common/service/VehColorService.php b/app/common/service/VehColorService.php new file mode 100644 index 0000000..2060efe --- /dev/null +++ b/app/common/service/VehColorService.php @@ -0,0 +1,33 @@ +logic = (new VehColorLogic()); + } + + /** + * 获取系统list + */ + public function getList($params) + { + if (empty($params['type'])) + throw new BusinessException('类型不能为空'); + $where = []; + if(isset($params['type']) && $params['type']) + $where[] = ['type', '=', $params['type']]; + return $this->logic->getList($where); + } +} \ No newline at end of file diff --git a/app/common/service/VehDictI18nService.php b/app/common/service/VehDictI18nService.php new file mode 100644 index 0000000..1c738d2 --- /dev/null +++ b/app/common/service/VehDictI18nService.php @@ -0,0 +1,75 @@ +validator; + } + + + /** 本进程缓存:[dict][code] => labels array */ + protected static array $labelsCache = []; + + public static function label(string $dict=null, ?string $code=null, string $locale='zh-CN', ?string $fallbackZh=null): ?string + { + if (!$dict || !$code) return $fallbackZh; + + $labels = self::labels($dict, $code); + if (!$labels) return $fallbackZh ?? $code; + + return $labels[$locale] ?? $labels['zh-CN'] ?? $code; + } + + /** 返回 labels 数组,命中缓存 */ + public static function labels(string $dict, string $code): ?array + { + $dict = strtoupper($dict); + $code = (string)$code; + + if (isset(self::$labelsCache[$dict][$code])) { + return self::$labelsCache[$dict][$code]; + } + + $term = DictTerm::where('dict',$dict)->where('code',$code)->first(['labels']); + if (!$term) return null; + + $labels = $term->labels ?? null; + if (!is_array($labels)) return null; + + self::$labelsCache[$dict][$code] = $labels; + return $labels; + } + + /** 校验 code 是否存在于字典(用于必填维度) */ + public static function ensureExists(string $dict, ?string $code, string $fieldNameForError): void + { + if ($code === null || $code === '') { + throw new \InvalidArgumentException("缺少必填字段:{$fieldNameForError}"); + } + $ok = VehDictI18n::where('dict',$dict)->where('code',$code)->where('status', 1)->exists(); + if (!$ok) throw new \InvalidArgumentException("字典不存在或未启用:{$dict}.{$code}"); + } +} \ No newline at end of file diff --git a/app/common/service/VehDictMappingService.php b/app/common/service/VehDictMappingService.php new file mode 100644 index 0000000..67c5288 --- /dev/null +++ b/app/common/service/VehDictMappingService.php @@ -0,0 +1,57 @@ +validator; + } + + + public static function all(): array + { + $rows = VehDictMapping::where('status', 1)->orderBy('sort')->orderBy('id')->get()->toArray(); + $out = []; + foreach ($rows as $r) { + $out[$r['dict']] = [ + 'field' => $r['field'], + 'dict' => $r['dict'], + 'required' => (int)$r['required'] === 1, + ]; + } + return $out; + } + + public static function requiredDicts(): array + { + return array_keys(array_filter(self::all(), fn($x)=>$x['required']===true)); + } + + public static function enabled(string $dict): bool + { + $all = self::all(); + return isset($all[$dict]); + } +} \ No newline at end of file diff --git a/app/common/service/VehModificationService.php b/app/common/service/VehModificationService.php new file mode 100644 index 0000000..31c7119 --- /dev/null +++ b/app/common/service/VehModificationService.php @@ -0,0 +1,28 @@ +logic = (new VehModificationLogic()); + } + + /** + * 获取系统list + */ + public function getList() + { + return $this->logic->getList(); + } +} \ No newline at end of file diff --git a/app/common/service/VehMoneyValueService.php b/app/common/service/VehMoneyValueService.php new file mode 100644 index 0000000..af45547 --- /dev/null +++ b/app/common/service/VehMoneyValueService.php @@ -0,0 +1,77 @@ +validator; + } + + + /** + * 复制金额记录 + * + * @param string $fromScene 源场景 + * @param int $fromId 源对象ID + * @param string $toScene 目标场景 + * @param int $toId 目标对象ID + * @return int 插入条数 + * @throws BusinessException + */ + public function copyMoneyValues(string $fromScene, int $fromId, string $toScene, int $toId): int + { + // 1. 查询源数据 + $rows = Db::table('veh_money_value') + ->where('object_scene', $fromScene) + ->where('object_id', $fromId) + ->get() + ->toArray(); + + if (empty($rows)) { + return 0; + } + + $now = time(); + $insertData = []; + + foreach ($rows as $row) { + $insertData[] = [ + 'object_scene' => $toScene, + 'object_id' => $toId, + 'field' => $row->field, + 'currency' => $row->currency, + 'amount' => $row->amount, + 'exchange_rate' => $row->exchange_rate, + 'remark' => $row->remark, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + // 2. 插入新数据 + return Db::table('veh_money_value')->insert($insertData); + } +} \ No newline at end of file diff --git a/app/common/service/VehVehicleListingService.php b/app/common/service/VehVehicleListingService.php new file mode 100644 index 0000000..3aee146 --- /dev/null +++ b/app/common/service/VehVehicleListingService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/VehVehicleSkuService.php b/app/common/service/VehVehicleSkuService.php new file mode 100644 index 0000000..a85ebcf --- /dev/null +++ b/app/common/service/VehVehicleSkuService.php @@ -0,0 +1,250 @@ +logic = (new VehVehicleSkuLogic()); + } + + /** + * 获取验证器 + */ + public function getValidator() + { + return new $this->validator; + } + + + // // 枚举 + // private const MAPS = [ + // 'BODY' => [ + // 'SEDAN' => '三厢轿车', 'SUV'=>'SUV', 'HB'=>'两厢/掀背', 'MPV'=>'MPV', 'PICKUP'=>'皮卡' + // ], + // 'DRIVE' => [ + // 'FWD'=>'前驱', 'RWD'=>'后驱', 'AWD'=>'四驱(AWD)', '4WD'=>'四驱(4WD)' + // ], + // 'MOTOR_LAYOUT' => [ + // 'FWD'=>'前置单电机', 'RWD'=>'后置单电机', 'AWD'=>'双电机四驱', 'DM-I'=>'DM-i', 'DM-P'=>'DM-p' + // ], + // 'FUEL' => [ + // 'GAS'=>'汽油', 'HEV'=>'油电混动', 'PHEV'=>'插电混动', 'EV'=>'纯电', 'EREV'=>'增程' + // ], + // 'TRANS' => [ + // 'MT'=>'手动', 'AT'=>'自动', 'CVT'=>'无级', 'DCT'=>'双离合', '1SPEED'=>'单速' + // ], + // 'RANGE_STD' => [ + // 'CLTC'=>'CLTC', 'WLTP'=>'WLTP', 'EPA'=>'EPA', 'NEDC'=>'NEDC' + // ], + // 'EMISSION' => [ + // 'CHN6'=>'国六', 'EURO6'=>'欧6', 'EPA'=>'美国EPA' + // ], + // ]; + + // // *_code取中文 + // public function label(string $dict, ?string $code): ?string + // { + // if ($code === null || $code === '') return null; + // $dict = strtoupper($dict); + // $code = strtoupper($code); + // return self::MAPS[$dict][$code] ?? $code; + // } + + // // 静态映射把 *_code → 中文 + // private function backfill(array $d): array + // { + // $d['body_type'] = $this->label('BODY', $d['body_type_code']); + // $d['drive_type'] = $this->label('DRIVE', $d['drive_type_code']); + // $d['motor_layout'] = $this->label('MOTOR_LAYOUT', $d['motor_layout_code']); + // $d['fuel_type'] = $this->label('FUEL', $d['fuel_type_code']); + // $d['transmission'] = $this->label('TRANS', $d['transmission_code']); + // $d['range_std'] = $this->label('RANGE_STD', $d['range_std_code']); + // $d['emission_std'] = $this->label('EMISSION', $d['emission_std_code']); + // return $d; + // } + + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'default': + $config['where']['is_active'] = 1; + $config['order']['id'] = 'desc'; + + // 关键字筛选 + if (isset($p['keyword']) && $p['keyword']) { + $k = '%' . $p['keyword'] . '%'; + foreach (['model_name','series_name','brand_name',] as $col) { + $config['orWhere'][] = [$col, 'like', $k]; + } + } + break; + case 'index': + $config['where']['is_active'] = 1; + $config['order']['id'] = 'desc'; + + // 关键字筛选 + if (isset($p['keyword']) && $p['keyword']) { + $k = '%' . $p['keyword'] . '%'; + foreach (['model_name','series_name','brand_name',] as $col) { + $config['orWhere'][] = [$col, 'like', $k]; + } + } + + if(isset($p['brand_id']) && $p['brand_id']){ + $brand_code = Brand::where('id', $p['brand_id'])->value('brand_code'); + $config['where']['brand_code'] = $brand_code; + } + if(isset($p['series_id']) && $p['series_id']){ + $series_code = BrandSeries::where('id', $p['series_id'])->value('series_code'); + $config['where']['series_code'] = $series_code; + } + break; + case 'show': + $config['where']['id'] = $p['id']; + $config['where']['is_active'] = 1; + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } + + public function beforeServiceStore($data, Request $request) + { + // $data = $this->backfill($data); + if (empty($data['model_code'])) { + $data['model_code'] = ModelNaming::buildModelCode($data); + } + if (empty($data['model_name'])) { + $data['model_name'] = ModelNaming::buildModelName($data); + } + if (empty($data['engine_disp_l'])) { + $data['engine_disp_l'] = 0; + } + if (empty($data['engine_turbo'])) { + $data['engine_turbo'] = 0; + } + if (empty($data['engine_power_kw'])) { + $data['engine_power_kw'] = 0; + } + if (empty($data['battery_kwh'])) { + $data['battery_kwh'] = 0; + } + if (empty($data['gears'])) { + $data['gears'] = 1; + } + if (empty($data['range_km'])) { //续航 + $data['range_km'] = 0; + } + if (empty($data['doors'])) { //车位数 + $data['doors'] = 0; + } + if (empty($data['seats'])) { //座位数 + $data['seats'] = 0; + } + if (empty($data['weight_kg'])) { //整车质量 + $data['weight_kg'] = 0; + } + $data['sku_code'] = SkuCodeBuilder::build($data); + $data['corp_id'] = 0; + $data['created_by'] = $request->user->uid; + $data['updated_by'] = $request->user->uid; + //指纹数组 + $fingerprintArr = [ + 'brand_name' => $data['brand_name'], + 'series_name' => $data['series_name'], + 'model_name' => $data['model_name'], + 'model_year' => $data['model_year'], + 'msrp' => $data['msrp'], + ]; + // 生成指纹 + $data['fingerprint'] = Fingerprint::makeFromArray($fingerprintArr); + return $data; + } + + protected function afterServiceStore($res) + { + + return $res; + } + + + public function beforeServiceUpdate($data, Request $request) + { + unset($data['created_at']); + unset($data['updated_at']); + // $data = $this->backfill($data); + if (empty($data['model_code'])) { + $data['model_code'] = ModelNaming::buildModelCode($data); + } + if (empty($data['model_name'])) { + $data['model_name'] = ModelNaming::buildModelName($data); + } + // 填充系统字段 + $data['updated_by'] = $request->user->uid; + //指纹数组 + $fingerprintArr = [ + 'brand_name' => $data['brand_name'], + 'series_name' => $data['series_name'], + 'model_name' => $data['model_name'], + 'model_year' => $data['model_year'], + 'msrp' => $data['msrp'], + ]; + // 生成指纹 + $data['fingerprint'] = Fingerprint::makeFromArray($fingerprintArr); + return $data; + } + + protected function afterServiceUpdate($res) + { + + return $res; + } + + /** + * 获取车型list + */ + public function getList($data) + { + return $this->logic->getList($data); + } +} \ No newline at end of file diff --git a/app/common/service/VehVehicleUnitService.php b/app/common/service/VehVehicleUnitService.php new file mode 100644 index 0000000..4af3ea4 --- /dev/null +++ b/app/common/service/VehVehicleUnitService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/traits/Auditable.php b/app/common/traits/Auditable.php new file mode 100644 index 0000000..e66341a --- /dev/null +++ b/app/common/traits/Auditable.php @@ -0,0 +1,48 @@ +pushModelChange(self::scene($m), $m->getKey(), [], $m->getAttributes()); + }); + + static::updated(function (Model $m) { + if (self::isMuted($m)) return; + $dirty = $m->getDirty(); + if (!$dirty) return; + $old = []; $new = []; + foreach (array_keys($dirty) as $k) { + $old[$k] = $m->getOriginal($k); + $new[$k] = $m->getAttribute($k); + } + AuditBuffer::instance()->pushModelChange(self::scene($m), $m->getKey(), $old, $new); + }); + + static::deleted(function (Model $m) { + if (self::isMuted($m)) return; + AuditBuffer::instance()->pushModelChange(self::scene($m), $m->getKey(), $m->getAttributes(), []); + }); + } + + protected static function scene(Model $m): string + { + return property_exists($m, 'auditScene') && $m->auditScene ? $m->auditScene : $m->getTable(); + } + + protected static function isMuted(Model $m): bool + { + if (property_exists($m, 'auditDisabled') && $m->auditDisabled) return true; + return $m->getTable() === 'sys_op_log'; // 避免自记录 + } +} \ No newline at end of file diff --git a/app/common/traits/DataPermission.php b/app/common/traits/DataPermission.php new file mode 100644 index 0000000..fa5e917 --- /dev/null +++ b/app/common/traits/DataPermission.php @@ -0,0 +1,425 @@ +where($userField, $currentUserId); + break; + + case 'same_role': + // 同角色权限:访问同角色的数据 + $userIds = self::getSameRoleUserIds($currentRoleId); + $query->whereIn($userField, $userIds); + break; + + case 'role_tree': + // 角色树权限:访问自己角色及子角色的数据 + $userIds = self::getRoleTreeUserIds($currentRoleId); + $query->whereIn($userField, $userIds); + break; + + case 'relation': + // 从属关系权限:访问自己及下属的数据 + $userIds = self::getRelationUserIds($currentUserId); + $query->whereIn($userField, $userIds); + break; + + default: + // 默认个人权限 + $query->where($userField, $currentUserId); + } + } + + /** + * 应用数据权限到创建/更新操作 + * + * @param mixed $target 创建时为数组,更新时为Model对象 + * @param string $type 权限类型 + * @param int $currentUserId 当前用户ID + * @param int $currentRoleId 当前角色ID + * @param array $newData 更新时的数据 + * @param string $userField 用户ID字段名 + * @param string $roleField 角色ID字段名 + */ + public static function applyMutationPermission( + &$target, + string $type, + int $currentUserId, + int $currentRoleId, + array $newData = [], + string $userField = 'admin_id', + string $roleField = 'role_id' + ): void { + // 超管不需要检查 + if (self::isSuperAdmin($currentUserId)) { + return; + } + + switch ($type) { + case 'personal': + self::handlePersonalPermission($target, $newData, $userField, $currentUserId); + break; + + case 'same_role': + self::handleSameRolePermission($target, $newData, $userField, $roleField, $currentRoleId); + break; + + case 'role_tree': + self::handleRoleTreePermission($target, $newData, $userField, $roleField, $currentRoleId); + break; + + case 'relation': + self::handleRelationPermission($target, $newData, $userField, $currentUserId); + break; + + default: + throw new BusinessException("未定义的数据权限类型: $type", 403); + } + } + + /** + * 获取同角色的用户ID列表 + * + * @param int $roleId 角色ID + * @return array 用户ID列表 + */ + public static function getSameRoleUserIds(int $roleId): array + { + return Db::table('user_roles') + ->where('role_id', $roleId) + ->pluck('user_id') + ->toArray(); + } + + /** + * 获取角色树下的所有用户ID列表 + * + * @param int $roleId 当前角色ID + * @return array 用户ID列表 + */ + public static function getRoleTreeUserIds(int $roleId): array + { + // 获取角色树中的所有角色ID + $roleIds = self::getRoleTreeIds($roleId); + + // 获取这些角色下的所有用户ID + return Db::table('user_roles') + ->whereIn('role_id', $roleIds) + ->pluck('user_id') + ->toArray(); + } + + /** + * 获取从属关系下的用户ID列表(包括自己和下属) + * + * @param int $userId 当前用户ID + * @return array 用户ID列表 + */ + public static function getRelationUserIds(int $userId): array + { + // 获取所有下属用户ID(递归) + $subordinateIds = self::getSubordinateIds($userId); + + // 返回自己和所有下属的ID + return array_merge([$userId], $subordinateIds); + } + + /** + * 获取直接下属的用户ID列表 + * + * @param int $userId 用户ID + * @return array 直接下属用户ID列表 + */ + public static function getDirectSubordinateIds(int $userId): array + { + return Db::table('user') + ->where('report_to', $userId) + ->pluck('id') + ->toArray(); + } + + /** + * 递归获取所有下属用户ID列表 + * + * @param int $userId 用户ID + * @return array 所有下属用户ID列表 + */ + private static function getSubordinateIds(int $userId): array + { + $subordinates = self::getDirectSubordinateIds($userId); + $allSubordinates = $subordinates; + + foreach ($subordinates as $subordinateId) { + $allSubordinates = array_merge($allSubordinates, self::getSubordinateIds($subordinateId)); + } + + return $allSubordinates; + } + + /** + * 处理个人权限 + */ + private static function handlePersonalPermission( + &$target, + array $newData, + string $userField, + int $currentUserId + ): void { + if (is_array($target)) { + // 创建时强制设置用户ID + $target[$userField] = $currentUserId; + } else { + // 更新时校验原始记录 + if ($target->{$userField} !== $currentUserId) { + throw new BusinessException("无权操作该记录", 403); + } + + // 禁止变更归属字段 + if (isset($newData[$userField]) && $newData[$userField] !== $currentUserId) { + throw new BusinessException("无权变更归属字段", 403); + } + } + } + + /** + * 处理同角色权限 + */ + private static function handleSameRolePermission( + &$target, + array $newData, + string $userField, + string $roleField, + int $currentRoleId + ): void { + // 获取同角色用户ID列表 + $sameRoleUserIds = self::getSameRoleUserIds($currentRoleId); + + if (is_array($target)) { + // 创建时设置归属用户并校验 + if (isset($target[$userField])) { + if (!in_array($target[$userField], $sameRoleUserIds)) { + throw new BusinessException("无权为指定用户创建数据", 403); + } + } else { + $target[$userField] = $currentRoleId; + } + } else { + // 更新时校验原始记录 + if (!in_array($target->{$userField}, $sameRoleUserIds)) { + throw new BusinessException("无权操作该记录", 403); + } + + // 禁止变更归属字段到无权限的用户 + if (isset($newData[$userField]) && !in_array($newData[$userField], $sameRoleUserIds)) { + throw new BusinessException("无权变更归属字段", 403); + } + } + } + + /** + * 处理角色树权限 + */ + private static function handleRoleTreePermission( + &$target, + array $newData, + string $userField, + string $roleField, + int $currentRoleId + ): void { + // 获取角色树用户ID列表 + $roleTreeUserIds = self::getRoleTreeUserIds($currentRoleId); + + if (is_array($target)) { + // 创建时校验用户是否在允许范围内 + if (isset($target[$userField])) { + if (!in_array($target[$userField], $roleTreeUserIds)) { + throw new BusinessException("无权为指定用户创建数据", 403); + } + } else { + $target[$userField] = $currentRoleId; + } + } else { + // 更新时校验原始记录用户 + if (!in_array($target->{$userField}, $roleTreeUserIds)) { + throw new BusinessException("无权操作该记录", 403); + } + + // 校验新用户是否在允许范围内 + if (isset($newData[$userField])) { + if (!in_array($newData[$userField], $roleTreeUserIds)) { + throw new BusinessException("无权变更用户字段", 403); + } + } + } + } + + /** + * 处理从属关系权限 + */ + private static function handleRelationPermission( + &$target, + array $newData, + string $userField, + int $currentUserId + ): void { + // 获取从属关系用户ID列表 + $relationUserIds = self::getRelationUserIds($currentUserId); + + if (is_array($target)) { + // 创建时校验用户是否在允许范围内 + if (isset($target[$userField])) { + if (!in_array($target[$userField], $relationUserIds)) { + throw new BusinessException("无权为指定用户创建数据", 403); + } + } else { + $target[$userField] = $currentUserId; + } + } else { + // 更新时校验原始记录用户 + if (!in_array($target->{$userField}, $relationUserIds)) { + throw new BusinessException("无权操作该记录", 403); + } + + // 校验新用户是否在允许范围内 + if (isset($newData[$userField])) { + if (!in_array($newData[$userField], $relationUserIds)) { + throw new BusinessException("无权变更用户字段", 403); + } + } + } + } + + /** + * 获取角色树ID(当前角色及其所有子角色) + * 从数据库中动态获取角色树关系 + */ + private static function getRoleTreeIds(int $roleId): array + { + static $roleTreeCache = []; + + if (isset($roleTreeCache[$roleId])) { + return $roleTreeCache[$roleId]; + } + + // 从数据库中递归获取角色树 + $ids = [$roleId]; + $queue = [$roleId]; + + while (!empty($queue)) { + $current = array_shift($queue); + // 查找当前角色的所有子角色 + $childRoles = Db::table('roles') + ->where('parent_id', $current) + ->where('status', 1) // 只获取启用的角色 + ->pluck('id') + ->toArray(); + + foreach ($childRoles as $childId) { + if (!in_array($childId, $ids)) { + $ids[] = $childId; + $queue[] = $childId; + } + } + } + + $roleTreeCache[$roleId] = $ids; + return $ids; + } + + /** + * 检查是否为超管 + * + * @param int $userId 用户ID + * @return bool 是否为超管 + */ + public static function isSuperAdmin(int $userId): bool + { + // 检查用户是否关联了超级管理员角色 + // 假设角色ID为1的是超级管理员角色 + $superAdminRoleId = 1; + + return Db::table('user_roles') + ->where('user_id', $userId) + ->where('role_id', $superAdminRoleId) + ->exists(); + } + + /** + * 获取用户的角色ID + * + * @param int $userId 用户ID + * @return int 角色ID + */ + public static function getUserIdRoleId(int $userId): int + { + // 从 user_roles 表中获取当前用户的角色ID + $roleId = Db::table('user_roles') + ->where('user_id', $userId) + ->value('role_id'); + + return $roleId ?: 0; + } + + /** + * 获取用户的所有角色ID(包括继承的角色) + * + * @param int $userId 用户ID + * @return array 角色ID列表 + */ + public static function getUserAllRoleIds(int $userId): array + { + // 获取用户直接关联的角色 + $directRoleIds = Db::table('user_roles') + ->where('user_id', $userId) + ->pluck('role_id') + ->toArray(); + + // 获取所有继承的角色 + $allRoleIds = []; + foreach ($directRoleIds as $roleId) { + $allRoleIds = array_merge($allRoleIds, self::getRoleTreeIds($roleId)); + } + + // 去重并返回 + return array_unique($allRoleIds); + } +} \ No newline at end of file diff --git a/app/common/traits/DataTransformer.php b/app/common/traits/DataTransformer.php new file mode 100644 index 0000000..adde8e6 --- /dev/null +++ b/app/common/traits/DataTransformer.php @@ -0,0 +1,152 @@ + callable 字段名格式化回调,如 fn($f)=>snake_case($f) + * - 'postFormatter' => callable 结果级加工回调,接收整个数组并返回 + * + * @return array 转换并加工后的纯 PHP 数组 + */ + protected function transformData( + mixed $rawData, + ?callable $postFormatter = null, + ?callable $fieldFormatter = null + ): array { + try { + $fieldFormatter = $fieldFormatter ?: fn($field) => $field; + if ($rawData instanceof LengthAwarePaginator) { + $arr = $this->formatPaginated($rawData, $postFormatter, $fieldFormatter); + } elseif ($rawData instanceof Model) { + $arr = $this->formatModel($rawData, $fieldFormatter); + } elseif ($rawData instanceof Collection) { + $arr = $this->formatCollection($rawData, $fieldFormatter); + } elseif (is_array($rawData)) { + $arr = $this->formatArray($rawData, $fieldFormatter); + } else { + $arr = (array)$rawData; + } + + if (is_callable($postFormatter)) { + $arr = $postFormatter($arr); + } + + return $arr; + } catch (\Throwable $e) { + return [ + 'error' => true, + 'message' => $e->getMessage(), + // 'message' => $e->getMessage()."\n".$e->getLine()."\n".$e->getFile(), + 'code' => $e->getCode(), + ]; + } + } + + /** + * 格式化分页数据:转换每条记录 + * + * @param LengthAwarePaginator $p + * @param callable $fmt + * @return array{records: array, total: int, current: int, size: int} + */ + protected function formatPaginated( + LengthAwarePaginator $p, + ?callable $postFormatter, + callable $fmt + ): array { + $records = $p->getCollection() + ->map(fn($item) => $this->transformData($item, $postFormatter, $fmt)) + ->toArray(); + + return [ + 'records' => $records, + 'total' => $p->total(), + 'current' => $p->currentPage(), + 'size' => $p->perPage(), + ]; + } + + /** + * 格式化 Eloquent 模型:主表属性 + 关联递归 + * + * @param Model $model + * @param callable $fmt + * @return array + */ + protected function formatModel( + Model $model, + callable $fmt + ): array { + $out = []; + // 1) 主表字段 + foreach ($model->getAttributes() as $key => $value) { + $out[$fmt($key)] = $value; + } + // 2) 关联递归 + foreach ($model->getRelations() as $rel => $relData) { + if ($relData instanceof Model) { + $out[$rel] = $this->formatModel($relData, $fmt); + } elseif ($relData instanceof Collection) { + $out[$rel] = $relData + ->map(fn($item) => $this->transformData($item, null, $fmt)) + ->toArray(); + } else { + $out[$rel] = $relData; + } + } + return $out; + } + + /** + * 格式化集合数据:对每个元素递归调用 transformData + * + * @param Collection $c + * @param callable $fmt + * @return array + */ + protected function formatCollection( + Collection $c, + callable $fmt + ): array { + return $c + ->map(fn($item) => $this->transformData($item, null, $fmt)) + ->toArray(); + } + + /** + * 格式化普通数组:递归处理可能出现的模型或集合 + * + * @param array $arr + * @param callable $fmt + * @return array + */ + protected function formatArray( + array $arr, + callable $fmt + ): array { + return array_map(function($item) use ($fmt) { + if (is_array($item) + || $item instanceof Model + || $item instanceof Collection + || $item instanceof LengthAwarePaginator + ) { + return $this->transformData($item, null, $fmt); + } + return $item; + }, $arr); + } +} \ No newline at end of file diff --git a/app/common/traits/FormatKvTrait.php b/app/common/traits/FormatKvTrait.php new file mode 100644 index 0000000..c3045a7 --- /dev/null +++ b/app/common/traits/FormatKvTrait.php @@ -0,0 +1,154 @@ +, + * fields: string[], + * include: string[] + * } + */ + protected function buildQueryConfig(Request $request, string $modelClass): array + { + $all = $request->all(); + + return [ + 'model' => $modelClass, + 'where' => $this->parseFilters($all), + 'order' => $this->parseSort($all['sort'] ?? ''), + 'fields' => $this->parseCsv($all['fields'] ?? ''), + 'include' => $this->parseCsv($all['include'] ?? ''), + ]; + } + + /** + * 把除 sort/fields/include/page/limit 以外的所有参数 + * 智能转换为 where 条件 + * + * 支持: + * - 等值:foo=bar + * - NOT:foo=!bar 或 foo=!bar1,bar2 + * - 范围:foo=2025-07-01,2025-07-15 → between + * - 列表:foo=a,b,c → in + * - NULL:foo=null + * - NOT NULL:foo=!null + * - 通配符:foo=xia* → like 'xia%' + * + * 关联字段直接写 relation.field=xxx + */ + protected function parseFilters(array $params): array + { + $where = []; + $reserved = ['sort','fields','include','page','limit']; + + foreach ($params as $key => $val) { + if (in_array($key, $reserved, true)) { + continue; + } + // NULL / NOT NULL + if ($val === 'null') { + $where[$key] = ['is null']; + continue; + } + if ($val === '!null') { + $where[$key] = ['is not null']; + continue; + } + // NOT equal or NOT IN + if (str_starts_with($val, '!')) { + $v = substr($val, 1); + if (str_contains($v, ',')) { + $list = array_map('trim', explode(',', $v)); + $where[$key] = ['not in', $list]; + } else { + $where[$key] = ['<>', $this->cast($v)]; + } + continue; + } + // BETWEEN or IN + if (str_contains($val, ',')) { + $parts = array_map('trim', explode(',', $val)); + if (count($parts) === 2 && $this->isRangeValue($parts[0], $parts[1])) { + $where[$key] = ['between', $parts]; + } else { + $where[$key] = ['in', $this->castArray($parts)]; + } + continue; + } + // LIKE + if (str_contains($val, '*') || str_contains($val, '%')) { + $pattern = str_replace('*', '%', $val); + $where[$key] = ['like', $pattern]; + continue; + } + // 默认 EQ + $where[$key] = $this->cast($val); + } + + return $where; + } + + /** + * 解析 sort=id,-created_at → ['id'=>'asc','created_at'=>'desc'] + */ + protected function parseSort(string $sort): array + { + $order = []; + foreach (explode(',', $sort) as $piece) { + $f = trim($piece); + if ($f === '') { + continue; + } + if ($f[0] === '-') { + $order[substr($f, 1)] = 'desc'; + } else { + $order[$f] = 'asc'; + } + } + return $order; + } + + /** + * 逗号分割成数组 + */ + protected function parseCsv(string $csv): array + { + if ($csv === '') { + return []; + } + return array_filter(array_map('trim', explode(',', $csv))); + } + + /** 简单类型转换:全数字→int/float,其余保留原字符串 */ + protected function cast(string $v): mixed + { + if (is_numeric($v)) { + return strpos($v, '.') !== false ? (float)$v : (int)$v; + } + return $v; + } + + /** 批量类型转换 */ + protected function castArray(array $arr): array + { + return array_map(fn($v) => $this->cast($v), $arr); + } + + /** 判断两端是否可做 BETWEEN(数字或 YYYY-MM-DD 日期) */ + protected function isRangeValue(string $a, string $b): bool + { + return (is_numeric($a) && is_numeric($b)) + || (preg_match('/^\d{4}-\d{2}-\d{2}$/', $a) + && preg_match('/^\d{4}-\d{2}-\d{2}$/', $b)); + } +} diff --git a/app/common/traits/HasMoneyJournal.php b/app/common/traits/HasMoneyJournal.php new file mode 100644 index 0000000..5d2c92d --- /dev/null +++ b/app/common/traits/HasMoneyJournal.php @@ -0,0 +1,85 @@ +hasMany(VehMoneyValue::class, 'object_id') + ->where('object_scene', static::moneyObjectScene()); + } + + /** 取某费项的“最新记录”(按时间/id 倒序) */ + public function moneyLatest(string $field): ?VehMoneyValue + { + return $this->moneyJournal() + ->where('field', $field) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + } + + /** 追加一行金额 */ + public function addMoney(string $field, string $currency, $amount, ?string $rate = null, ?string $remark = null): VehMoneyValue + { + $now = time(); + /** @var VehMoneyValue $row */ + $row = $this->moneyJournal()->create([ + 'object_scene' => static::moneyObjectScene(), + 'object_id' => $this->id, + 'field' => $field, + 'currency' => strtoupper($currency), + 'amount' => (string)$amount, + 'exchange_rate' => $rate, + 'remark' => $remark, + 'created_at' => $now, + 'updated_at' => $now, + ]); + return $row; + } + + /** 批量追加:['unit_price'=>['currency'=>'USD','amount'=>'2199.00','rate'=>'7.2','remark'=>'...'], ...] */ + public function addMoneyBatch(array $payload): array + { + return Db::transaction(function () use ($payload) { + $out = []; + foreach ($payload as $field => $cfg) { + $out[$field] = $this->addMoney( + $field, + $cfg['currency'] ?? 'CNY', + $cfg['amount'] ?? '0.00', + $cfg['rate'] ?? null, + $cfg['remark'] ?? null + ); + } + return $out; + }); + } + + /** 把若干费项按各自汇率换到结算币并求和(无 rate 的跳过) */ + public function sumToSettlement(array $fields, int $scale = 2): string + { + $sum = '0.00'; + foreach ($fields as $f) { + $row = $this->moneyLatest($f); + if ($row) { + $v = $row->amountInSettlement($scale); + if ($v !== null) $sum = bcadd($sum, $v, $scale); + } + } + return $sum; + } +} \ No newline at end of file diff --git a/app/common/traits/RequestTrait.php b/app/common/traits/RequestTrait.php new file mode 100644 index 0000000..8fefcc4 --- /dev/null +++ b/app/common/traits/RequestTrait.php @@ -0,0 +1,38 @@ +scene($scene); + } + // 执行数据验证 + if (!$validator->check($data)) { + // 验证失败,抛出业务异常,异常信息为验证器返回的错误信息 + throw new BusinessException($validator->getError()); + } + // 验证成功,返回原始数据 + return $data; + } + + +} \ No newline at end of file diff --git a/app/common/traits/ResponseTrait.php b/app/common/traits/ResponseTrait.php new file mode 100644 index 0000000..cc20773 --- /dev/null +++ b/app/common/traits/ResponseTrait.php @@ -0,0 +1,77 @@ + 'application/json'] + ): Response { + return $this->result($status, $code, $httpStatus, $message, $data, $header); + } + + /** + * 返回错误响应 + * + * @param string $message 响应错误消息,默认“请求失败” + * @param int $code 内部状态码,默认 1 表示错误 + * @param int $httpStatus HTTP 状态码,默认 400 + * @param mixed $data 可选的错误数据 + * @param array $header 响应头,默认 JSON 格式 + * @return Response + */ + public function error( + string $message = '请求失败', + int $code = 400, + int $httpStatus = 400, + $data = [], + array $header = ['Content-Type' => 'application/json'] + ): Response { + return $this->result('fail', $code, $httpStatus, $message, $data, $header); + } + + /** + * 统一响应格式方法 + * + * @param string $status 响应状态,例如 "success" 或 "fail" + * @param int $code 内部状态码 + * @param int $httpStatus HTTP 状态码 + * @param string $message 响应消息 + * @param mixed $data 响应数据 + * @param array $header 响应头 + * @return Response + */ + protected function result( + string $status, + int $code, + int $httpStatus, + string $message, + $data, + array $header + ): Response { + $body = [ + 'status' => $status, + 'code' => $code, + 'message' => $message, + 'data' => $data, + ]; + return response(json_encode($body, JSON_UNESCAPED_UNICODE), $httpStatus, $header); + } +} \ No newline at end of file diff --git a/app/common/traits/crud/CrudControllerTrait.php b/app/common/traits/crud/CrudControllerTrait.php new file mode 100644 index 0000000..ca30ba3 --- /dev/null +++ b/app/common/traits/crud/CrudControllerTrait.php @@ -0,0 +1,148 @@ +service->serviceList($request); + $data = $this->transformData($raw, method_exists($this, 'indexFormat') + ? [$this, 'indexFormat'] + : null); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 获取详情 + * + * @param Request $request 请求对象 + * @param int|string $id 资源 ID + * @return mixed 返回资源详情数据 + */ + public function show(Request $request) + { + try { + $raw = $this->service->serviceShow($request); + $data = $this->transformData($raw, method_exists($this, 'showFormat') + ? [$this, 'showFormat'] + : null); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 创建 + * + * @param Request $request 请求对象 + * @return mixed 返回创建成功的数据或信息 + */ + public function store(Request $request) + { + try { + $data = $this->validate($this->service->getValidator(), $request->post(), 'store'); + $res = $this->service->serviceStore($request, $data); + return $this->success($res, '创建成功'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 更新 + * + * @param Request $request 请求对象 + * @return mixed 返回更新成功的数据或信息 + */ + public function update(Request $request) + { + try { + $data = $this->validate($this->service->getValidator(), $request->post(), 'update'); + $res = $this->service->serviceUpdate($request, $data); + return $this->success([], '更新成功'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 删除 + * + * @param Request $request 请求对象 + * @return mixed 返回删除操作结果 + */ + public function destroy(Request $request) + { + try { + $count = $this->service->serviceDestroy($request); + return $this->success([], "成功删除了 {$count} 条"); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + + // ---------------------------------------------------------------- + // 以下三个钩子方法,**在使用此 Trait 的 Controller** 中实现 + // ---------------------------------------------------------------- + + /** + * 字段名格式化:例如全部下划线 + * + * @param string $field + * @return string + */ + // protected function fieldFormatter(string $field): string + // { + // return snake_case($field); + // } + + /** + * 字段级后置加工规则 + * + * @return array + * 键为 “点号嵌套”或 “通配符” 字段路径, + * 值为 fn($value, $row)=>mixed + */ + // protected function getFieldTransforms(): array + // { + // return [ + // 'name' => fn($v,$row)=>strtoupper($v), + // 'orders.*.total' => fn($v,$row)=>number_format($v,2), + // ]; + // } + + /** + * 结果级后置加工:接收整个数组,可任意增删改 + * + * @param array $data + * @return array + */ + // protected function postFormat(array $data): array + // { + // // 例如重命名 records 为 items + // if (isset($data['records'])) { + // $data['items'] = $data['records']; + // unset($data['records']); + // } + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/traits/crud/CrudLogicTrait.php b/app/common/traits/crud/CrudLogicTrait.php new file mode 100644 index 0000000..9e629f9 --- /dev/null +++ b/app/common/traits/crud/CrudLogicTrait.php @@ -0,0 +1,80 @@ +modelClass; + return CrudUtil::paginate(CrudUtil::buildQuery($config), $limit, $page); + } + + /** + * 全量列表 + */ + public function logicListAll(array $config) + { + $config['model'] = $this->modelClass; + return CrudUtil::getAll(CrudUtil::buildQuery($config)); + } + + /** + * 查询单条 + */ + public function logicShow(array $config) + { + return CrudUtil::findOneBy(CrudUtil::buildQuery($config)); + } + + /** + * 创建(可带 dataLimit) + */ + public function logicCreate(array $data, array $dataLimit = []) + { + return CrudUtil::create($this->modelClass, $data, $dataLimit); + } + + /** + * 任意条件更新(可带 dataLimit) + */ + public function logicUpdateBy(array $config, array $data) + { + return CrudUtil::updateBy(CrudUtil::buildQuery($config), $data); + } + + /** + * 任意条件删除(可带 dataLimit) + */ + public function logicDeleteBy(array $config) + { + return CrudUtil::deleteBy(CrudUtil::buildQuery($config)); + } + + /** + * 复杂查询回调 + */ + public function logicComplex(callable $callback) + { + return CrudUtil::executeComplex($this->modelClass, $callback); + } + + /** + * 根据主键查询 + */ + public function findByPk(int $id, array $with = []) + { + return CrudUtil::findByPk($this->modelClass, $id, $with); + } +} diff --git a/app/common/traits/crud/CrudServiceTrait.php b/app/common/traits/crud/CrudServiceTrait.php new file mode 100644 index 0000000..d956cad --- /dev/null +++ b/app/common/traits/crud/CrudServiceTrait.php @@ -0,0 +1,202 @@ +validator; + } + + /** 分页列表 */ + public function serviceList(Request $request, string $scene = 'index') + { + $result = $this->cqyProcess(ucfirst($scene), $request); + if ($result === false) { + $config = $this->buildQueryConfig($request, $scene); + $limit = (int)$request->get('size', 15); + $page = (int)$request->get('current', 1); + $result = $this->logicList($config, $limit, $page); + } + return $this->postProcess(ucfirst($scene), $result); + } + + /** 全量列表 */ + public function serviceListAll(Request $request, string $scene = 'default') + { + $result = $this->cqyProcess(ucfirst($scene), $request); + if ($result === false) { + $config = $this->buildQueryConfig($request, $scene); + $result = $this->logicListAll($config); + } + return $this->postProcess(ucfirst($scene), $result); + } + + /** 查看单条 */ + public function serviceShow(Request $request, string $scene = 'show') + { + $config = $this->buildQueryConfig($request, $scene); + $item = $this->logicShow($config); + return $this->postProcess(ucfirst($scene), $item); + } + + /** 创建 */ + public function serviceStore(Request $request, array $data = [], string $scene = 'store') + { + $data = $this->preProcess(ucfirst($scene), $data, $request); + $dl = $this->getDataLimitFromRequest($request); + $created = $this->logicCreate($data, $dl); + return $this->postProcess(ucfirst($scene), $created); + } + + /** 更新 */ + public function serviceUpdate(Request $request, array $data = [], string $scene = 'update') + { + $data = $this->preProcess(ucfirst($scene), $data, $request); + $config = $this->buildQueryConfig($request, $scene); + $updated = $this->logicUpdateBy($config, $data); + return $this->postProcess(ucfirst($scene), $data); + } + + /** 删除 */ + public function serviceDestroy(Request $request, string $scene = 'destroy') + { + $config = $this->buildQueryConfig($request, $scene); + $deleted = $this->logicDeleteBy($config); + return $this->postProcess(ucfirst($scene), $deleted); + } + + /** ----------------- 公共辅助方法 ----------------- */ + + /** + * 构造 CrudUtil 查询配置 + */ + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'default': + $config['where']['status'] = 1; + $config['order']['sort'] = 'desc'; + $config['order']['id'] = 'desc'; + break; + case 'index': + $config['where']['status'] = 1; + $config['order']['sort'] = 'desc'; + $config['order']['id'] = 'desc'; + break; + case 'show': + $config['where']['id'] = $p['id']; + $config['where']['status'] = 1; + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } + + /** 解析 withFields 字符串 "rel:field,field;..." */ + protected function parseWithFields(string $str): array + { + if (!$str) { + return []; + } + $out = []; + foreach (explode(';', $str) as $pair) { + [$rel, $flds] = explode(':', $pair); + $out[$rel] = explode(',', $flds); + } + return $out; + } + + /** 从请求读取 dataLimitType, dataLimitField */ + protected function getDataLimitFromRequest(Request $req): array + { + $type = $req->get('dataLimitType'); + if (!$type) { + return []; + } + return [ + 'type' => $type, + 'field' => $req->get('dataLimitField', 'admin_id'), + ]; + } + + /** + * $data 前置处理 + * + * @param array $data + * @param Request $request + * @return array + */ + protected function preProcess(string $op, mixed $data, Request $request): mixed + { + $hook = "beforeService{$op}"; + return method_exists($this, $hook) + ? $this->{$hook}($data, $request) + : $data; + } + + /** + * 根据操作类型,调用对应的后置钩子 + * + * @param string $op 操作:List, Show, Store, Update, Destroy + * @param mixed $data 原始返回值 + * @return mixed 处理后的返回值 + */ + protected function postProcess(string $op, mixed $data): mixed + { + $hook = "afterService{$op}"; + if (method_exists($this, $hook)) { + $args = func_get_args(); + array_shift($args); + array_shift($args); + $params = array_merge([$data], $args); + return call_user_func_array([$this, $hook], $params); + } + return $data; + } + + /** + * 复杂/自定义查询 + * + * @param string $op 场景 + * @param mixed $data 原始返回值 + * @return mixed 处理后的返回值 + */ + protected function cqyProcess(string $op, Request $request): mixed + { + $hook = "complexQuery{$op}"; + return method_exists($this, $hook) + ? $this->{$hook}($request) + : false; + } +} \ No newline at end of file diff --git a/app/common/traits/crud/CrudServiceTrait_bak.php b/app/common/traits/crud/CrudServiceTrait_bak.php new file mode 100644 index 0000000..4b2e012 --- /dev/null +++ b/app/common/traits/crud/CrudServiceTrait_bak.php @@ -0,0 +1,194 @@ +validator; + } + + /** 分页列表 */ + public function serviceList(Request $request) + { + if (method_exists($this, 'complexQuery')) { + $result = $this->complexQuery($request->get()); + } else { + $config = $this->buildQueryConfig($request, 'index'); + $limit = (int)$request->get('size', 15); + $page = (int)$request->get('current', 1); + $result = $this->logicList($config, $limit, $page); + } + return $this->postProcess('List', $result); + } + + /** 全量列表 */ + public function serviceListAll(Request $request) + { + if (method_exists($this, 'complexQuery')) { + $result = $this->complexQuery($request->get()); + } else { + $config = $this->buildQueryConfig($request, 'default'); + $result = $this->logicListAll($config); + } + return $this->postProcess('ListAll', $result); + } + + /** 查看单条 */ + public function serviceShow(Request $request) + { + $config = $this->buildQueryConfig($request, 'show'); + $item = $this->logicShow($config); + return $this->postProcess('Show', $item); + } + + /** 创建 */ + public function serviceStore(Request $request, array $data = []) + { + $data = $this->preProcess('Store', $data, $request); + $dl = $this->getDataLimitFromRequest($request); + $created = $this->logicCreate($data, $dl); + return $this->postProcess('Store', $created); + } + + /** 更新 */ + public function serviceUpdate(Request $request, array $data = []) + { + $data = $this->preProcess('Update', $data, $request); + $config = $this->buildQueryConfig($request, 'update'); + $updated = $this->logicUpdateBy($config, $data); + return $this->postProcess('Update', $data); + } + + /** 删除 */ + public function serviceDestroy(Request $request) + { + $config = $this->buildQueryConfig($request, 'destroy'); + $deleted = $this->logicDeleteBy($config); + return $this->postProcess('Destroy', $deleted); + } + + /** ----------------- 公共辅助方法 ----------------- */ + + /** + * 构造 CrudUtil 查询配置 + */ + protected function buildQueryConfig(Request $request, string $scene = 'default'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'default': + $config['where']['status'] = 1; + $config['order']['sort'] = 'desc'; + $config['order']['id'] = 'desc'; + break; + case 'index': + $config['where']['status'] = 1; + $config['order']['sort'] = 'desc'; + $config['order']['id'] = 'desc'; + break; + case 'show': + $config['where']['id'] = $p['id']; + $config['where']['status'] = 1; + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } + + /** 解析 withFields 字符串 "rel:field,field;..." */ + protected function parseWithFields(string $str): array + { + if (!$str) { + return []; + } + $out = []; + foreach (explode(';', $str) as $pair) { + [$rel, $flds] = explode(':', $pair); + $out[$rel] = explode(',', $flds); + } + return $out; + } + + /** 从请求读取 dataLimitType, dataLimitField */ + protected function getDataLimitFromRequest(Request $req): array + { + $type = $req->get('dataLimitType'); + if (!$type) { + return []; + } + return [ + 'type' => $type, + 'field' => $req->get('dataLimitField', 'admin_id'), + ]; + } + + /** + * $data 前置处理 + * + * @param array $data + * @param Request $request + * @return array + */ + protected function preProcess(string $op, mixed $data, Request $request): mixed + { + $hook = "beforeService{$op}"; + return method_exists($this, $hook) + ? $this->{$hook}($data, $request) + : $data; + } + + /** + * 根据操作类型,调用对应的后置钩子 + * + * @param string $op 操作:List, Show, Store, Update, Destroy + * @param mixed $data 原始返回值 + * @return mixed 处理后的返回值 + */ + protected function postProcess(string $op, mixed $data): mixed + { + $hook = "afterService{$op}"; + return method_exists($this, $hook) + ? $this->{$hook}($data) + : $data; + } + + + /** + * 处理复杂查询 + * 覆盖此方法,实现复杂查询逻辑 + */ + // protected function complexQuery(array $params): array + // { + // throw new BusinessException('请在子类实现 complexQuery'); + // } +} \ No newline at end of file diff --git a/app/common/utils/CrudUtil.php b/app/common/utils/CrudUtil.php new file mode 100644 index 0000000..90e7846 --- /dev/null +++ b/app/common/utils/CrudUtil.php @@ -0,0 +1,808 @@ + User::class, + * 'where' => [ + * 'status' => 1, + * 'name' => ['like','%张%'], + * 'roles.name' => '管理员', // 关联表字段过滤 + * 'posts.created_at'=> ['>','2025-01-01'], + * ], + * 'order' => ['id'=>'desc'], + * 'fields' => ['id','name','email'], + * 'with' => ['roles','posts'], + * 'withFields' => [ + * 'roles' => ['id','name'], // 关联表字段选择 + * 'posts' => ['id','title','user_id'], + * ], + * 'dataLimit' => [ + * 'type' => 'personal|same_role|role_tree|relation', // personal:个人数据,same_role:同角色,role_tree:角色树,relation:从属关联数据 + * 'userId' => 1, + * 'roleId' => 2, + * 'userField' => 'user_id', + * 'roleField' => 'role_id' + * ] + * ]; + */ + public static function buildQuery(array $config): Builder + { + /** @var Model $model */ + $model = new $config['model']; + $q = $model->newQuery(); + + // eager-load:支持 ['rel','rel2'] 和 ['rel'=>fields[], ...] + if (! empty($config['with'])) { + foreach ($config['with'] as $rel => $fields) { + if (is_int($rel)) { + // 普通关联 + $q->with($fields); + } else { + // 带字段筛选的关联 + $q->with([$rel => function ($qr) use ($fields) { + $key = $qr->getModel()->getKeyName(); + if (! in_array($key, $fields, true)) { + $fields[] = $key; + } + $qr->select($fields); + }]); + } + } + } + + // 表字段 + if (!empty($config['fields'])) { + $q->select($config['fields']); + } + + // where + if (!empty($config['where'])) { + foreach ($config['where'] as $col => $val) { + if (strpos($col, '.') !== false) { + // 多层支持 + $segments = explode('.', $col); + $leaf = array_pop($segments); + self::applyNestedWhere($q, $segments, $leaf, $val); + } else { + self::applyWhere($q, $col, $val); + } + } + } + + // or where + if (!empty($config['orWhere'])) { + $q->where(function($q) use($config) { + foreach ($config['orWhere'] as $cond) { + [$col,$op,$val] = $cond; + $q->orWhere($col,$op,$val); + } + }); + } + + // 排序 + if (!empty($config['order'])) { + foreach ($config['order'] as $col => $dir) { + $q->orderBy($col, strtolower($dir) === 'asc' ? 'asc' : 'desc'); + } + } + + // 数据权限过滤 + if (!empty($config['dataLimit'])) { + self::applyDataLimit($q, $config['dataLimit']); + } + + return $q; + } + + /** + * 分页查询 + * + * @param Builder $query + * @param int $perPage + * @param int $page + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + */ + public static function paginate(Builder $q, int $perPage, int $page) + { + return $q->paginate($perPage, ['*'], 'page', $page); + } + + /** + * 非分页查询 + * + * @param Builder $query + * @return \Illuminate\Database\Eloquent\Collection + */ + public static function getAll(Builder $q) + { + return $q->get(); + } + + /** + * 执行复杂查询——仅在显式调用时生效 + * + * @param string $modelClass 模型类 + * @param callable $callback function(Builder $q) { ... } + * @return mixed 由 callback 返回的结果 + */ + public static function executeComplex(string $modelClass, callable $cb) + { + /** @var Model $model */ + $model = new $modelClass; + return $cb($model->newQuery()); + } + + /** + * 主键单条查询 + * + * @param string $modelClass + * @param mixed $id + * @param array $with + * @return Model + * @throws BusinessException + */ + public static function findByPk(string $modelClass, $id, array $with = []): Model + { + $m = $modelClass::with($with)->find($id); + if (!$m) { + throw new BusinessException("记录 ID={$id} 不存在", 404); + } + return $m; + } + + /** + * 任意条件单条查询,支持 dataLimit + * + * @param Builder $query + * @return Model + * @throws BusinessException + */ + public static function findOneBy(Builder $q): Model + { + $model = $q->first(); + if (!$model) { + throw new BusinessException("记录不存在", 404); + } + return $model; + } + + /** + * 创建,支持 dataLimit + * + * @param string $modelClass + * @param array $data + * @param array $dataLimit + * @return Model + * @throws BusinessException + */ + public static function create( + string $modelClass, + array $data, + array $dataLimit = [] + ): Model { + // dataLimit 检查/注入 + if (!empty($dataLimit)) { + self::applyDataLimitOnMutate($data, $dataLimit); + } + return $modelClass::create($data); + } + + /** + * 主键更新,支持 dataLimit + * + * @param string $modelClass + * @param mixed $id + * @param array $data + * @param array $dataLimit + * @return Model + * @throws BusinessException + */ + public static function updateByPk( + string $modelClass, + $id, + array $data, + array $dataLimit = [] + ): Model { + /** @var Model $m */ + $m = $modelClass::find($id); + if (!$m) { + throw new BusinessException("记录 ID={$id} 不存在", 404); + } + // dataLimit 校验 + if (!empty($dataLimit)) { + self::applyDataLimitOnMutate($m, $dataLimit, $data); + } + $m->fill($data)->save(); + return $m; + } + + /** + * 非主键条件更新(批量),支持 dataLimit + * + * @param Builder $query + * @param array $data + * @return int 受影响行数 + */ + public static function updateBy(Builder $q, array $data): int + { + return $q->update($data); + } + + /** + * 主键删除(支持批量) + * + * @param string $modelClass + * @param array $ids + * @return int 删除条数 + */ + public static function deleteByPk(string $modelClass, array $ids): int + { + return $modelClass::destroy($ids); + } + + /** + * 非主键条件删除,支持 dataLimit + * + * @param Builder $query + * @return int 删除条数 + */ + public static function deleteBy(Builder $q): int + { + return $q->delete(); + } + + + //=================== Internal Methods ===================// + + + /** + * 支持多层的 whereHas:$relations=['roles','permissions'],$leaf='name' + */ + protected static function applyNestedWhere(Builder $q, array $relations, string $leaf, $val): void + { + $rel = array_shift($relations); + $q->whereHas($rel, function (Builder $qr) use ($relations, $leaf, $val) { + if ($relations) { + // 还有更深层,继续递归 + self::applyNestedWhere($qr, $relations, $leaf, $val); + } else { + // 最后一层,使用普通 applyWhere + self::applyWhere($qr, $leaf, $val); + } + }); + } + + /** + * 单字段 where 支持: + * - 多条件数组 [ [op1,val1], [op2,val2], … ] + * - 单条件 ['op', val] + * - 直接值 + * + * @param Builder $q + * @param string $col + * @param mixed $val + * @return void + */ + protected static function applyWhere(Builder $q, string $col, $val): void + { + // 多条件数组 + if (is_array($val) && isset($val[0]) && is_array($val[0])) { + foreach ($val as $pair) { + if (is_array($pair) && count($pair) === 2) { + self::applySingleWhere($q, $col, $pair[0], $pair[1]); + } + } + } + // 单条件 + elseif (is_array($val) && count($val) === 2) { + self::applySingleWhere($q, $col, $val[0], $val[1]); + } + // 直接等值 + else { + $q->where($col, $val); + } + } + + /** + * 执行条件 where + */ + protected static function applySingleWhere(Builder $q, string $col, string $op, $v): void + { + switch (strtolower($op)) { + case 'like': + $q->where($col, 'like', $v); + break; + case 'in': + $q->whereIn($col, (array)$v); + break; + case 'not in': + $q->whereNotIn($col, (array)$v); + break; + case 'null': + $q->whereNull($col); + break; + case 'not null': + $q->whereNotNull($col); + break; + case 'between': + $q->whereBetween($col, (array)$v); + break; + default: + $q->where($col, $op, $v); + } + } + + + // ================== Data Limit ================== + + /** + * 应用数据权限过滤到查询(buildQuery 场景) + */ + protected static function applyDataLimit(Builder $q, array $dataLimit): void + { + $type = $dataLimit['type'] ?? 'personal'; + $userId = $dataLimit['userId'] ?? 0; + $roleId = $dataLimit['roleId'] ?? 0; + $userField = $dataLimit['userField'] ?? 'user_id'; + $roleField = $dataLimit['roleField'] ?? 'role_id'; + + self::applyQueryPermission($q, $type, $userId, $roleId, $userField, $roleField); + } + + /** + * 应用数据权限到"创建/更新"操作 + * + * - $target 可能是 array|Model 对象 + * - $newData 仅在 updateByPk 场景时传入,用以校验变更归属 + */ + protected static function applyDataLimitOnMutate(&$target, array $dataLimit, array $newData = []): void + { + $type = $dataLimit['type'] ?? 'personal'; + $userId = $dataLimit['userId'] ?? 0; + $roleId = $dataLimit['roleId'] ?? 0; + $userField = $dataLimit['userField'] ?? 'user_id'; + $roleField = $dataLimit['roleField'] ?? 'role_id'; + + self::applyMutationPermission($target, $type, $userId, $roleId, $newData, $userField, $roleField); + } + + /** + * 应用数据权限过滤到查询 + * + * @param Builder $query 查询构造器 + * @param string $type 权限类型:superadmin, personal, same_role, role_tree, relation + * @param int $currentUserId 当前用户ID + * @param int $currentRoleId 当前角色ID + * @param string $userField 用户ID字段名 + * @param string $roleField 角色ID字段名 + */ + public static function applyQueryPermission( + Builder $query, + string $type, + int $currentUserId = 0, + int $currentRoleId = 0, + string $userField = 'user_id', + string $roleField = 'role_id' + ): void { + // 超管不需要过滤 + if (self::isSuperAdmin($currentUserId)) { + return; + } + + switch ($type) { + case 'personal': + // 个人权限:只能访问自己的数据 + $query->where($userField, $currentUserId); + break; + + case 'same_role': + // 同角色权限:访问同角色的数据 + $userIds = self::getSameRoleUserIds($currentRoleId); + $query->whereIn($userField, $userIds); + break; + + case 'role_tree': + // 角色树权限:访问自己角色及子角色的数据 + $userIds = self::getRoleTreeUserIds($currentRoleId); + $query->whereIn($userField, $userIds); + break; + + case 'relation': + // 从属关系权限:访问自己及下属的数据 + $userIds = self::getRelationUserIds($currentUserId); + $query->whereIn($userField, $userIds); + break; + + default: + // 默认个人权限 + $query->where($userField, $currentUserId); + } + } + + /** + * 应用数据权限到创建/更新操作 + * + * @param mixed $target 创建时为数组,更新时为Model对象 + * @param string $type 权限类型 + * @param int $currentUserId 当前用户ID + * @param int $currentRoleId 当前角色ID + * @param array $newData 更新时的数据 + * @param string $userField 用户ID字段名 + * @param string $roleField 角色ID字段名 + */ + public static function applyMutationPermission( + &$target, + string $type, + int $currentUserId, + int $currentRoleId, + array $newData = [], + string $userField = 'user_id', + string $roleField = 'role_id' + ): void { + // 超管不需要检查 + if (self::isSuperAdmin($currentUserId)) { + return; + } + + switch ($type) { + case 'personal': + self::handlePersonalPermission($target, $newData, $userField, $currentUserId); + break; + + case 'same_role': + self::handleSameRolePermission($target, $newData, $userField, $roleField, $currentRoleId); + break; + + case 'role_tree': + self::handleRoleTreePermission($target, $newData, $userField, $roleField, $currentRoleId); + break; + + case 'relation': + self::handleRelationPermission($target, $newData, $userField, $currentUserId); + break; + + default: + throw new BusinessException("未定义的数据权限类型: $type", 403); + } + } + + /** + * 获取同角色的用户ID列表 + * + * @param int $roleId 角色ID + * @return array 用户ID列表 + */ + public static function getSameRoleUserIds(int $roleId): array + { + return Db::table('user_roles') + ->where('role_id', $roleId) + ->pluck('user_id') + ->toArray(); + } + + /** + * 获取角色树下的所有用户ID列表 + * + * @param int $roleId 当前角色ID + * @return array 用户ID列表 + */ + public static function getRoleTreeUserIds(int $roleId): array + { + // 获取角色树中的所有角色ID + $roleIds = self::getRoleTreeIds($roleId); + + // 获取这些角色下的所有用户ID + return Db::table('user_roles') + ->whereIn('role_id', $roleIds) + ->pluck('user_id') + ->toArray(); + } + + /** + * 获取从属关系下的用户ID列表(包括自己和下属) + * + * @param int $userId 当前用户ID + * @return array 用户ID列表 + */ + public static function getRelationUserIds(int $userId): array + { + // 获取所有下属用户ID(递归) + $subordinateIds = self::getSubordinateIds($userId); + + // 返回自己和所有下属的ID + return array_merge([$userId], $subordinateIds); + } + + /** + * 获取直接下属的用户ID列表 + * + * @param int $userId 用户ID + * @return array 直接下属用户ID列表 + */ + public static function getDirectSubordinateIds(int $userId): array + { + return Db::table('user') + ->where('report_to', $userId) + ->pluck('id') + ->toArray(); + } + + /** + * 递归获取所有下属用户ID列表 + * + * @param int $userId 用户ID + * @return array 所有下属用户ID列表 + */ + private static function getSubordinateIds(int $userId): array + { + $subordinates = self::getDirectSubordinateIds($userId); + $allSubordinates = $subordinates; + + foreach ($subordinates as $subordinateId) { + $allSubordinates = array_merge($allSubordinates, self::getSubordinateIds($subordinateId)); + } + + return $allSubordinates; + } + + /** + * 处理个人权限 + */ + private static function handlePersonalPermission( + &$target, + array $newData, + string $userField, + int $currentUserId + ): void { + if (is_array($target)) { + // 创建时强制设置用户ID + $target[$userField] = $currentUserId; + } else { + // 更新时校验原始记录 + if ($target->{$userField} !== $currentUserId) { + throw new BusinessException("无权操作该记录", 403); + } + + // 禁止变更归属字段 + if (isset($newData[$userField]) && $newData[$userField] !== $currentUserId) { + throw new BusinessException("无权变更归属字段", 403); + } + } + } + + /** + * 处理同角色权限 + */ + private static function handleSameRolePermission( + &$target, + array $newData, + string $userField, + string $roleField, + int $currentRoleId + ): void { + // 获取同角色用户ID列表 + $sameRoleUserIds = self::getSameRoleUserIds($currentRoleId); + + if (is_array($target)) { + // 创建时设置归属用户并校验 + if (isset($target[$userField])) { + if (!in_array($target[$userField], $sameRoleUserIds)) { + throw new BusinessException("无权为指定用户创建数据", 403); + } + } else { + $target[$userField] = $currentRoleId; + } + } else { + // 更新时校验原始记录 + if (!in_array($target->{$userField}, $sameRoleUserIds)) { + throw new BusinessException("无权操作该记录", 403); + } + + // 禁止变更归属字段到无权限的用户 + if (isset($newData[$userField]) && !in_array($newData[$userField], $sameRoleUserIds)) { + throw new BusinessException("无权变更归属字段", 403); + } + } + } + + /** + * 处理角色树权限 + */ + private static function handleRoleTreePermission( + &$target, + array $newData, + string $userField, + string $roleField, + int $currentRoleId + ): void { + // 获取角色树用户ID列表 + $roleTreeUserIds = self::getRoleTreeUserIds($currentRoleId); + + if (is_array($target)) { + // 创建时校验用户是否在允许范围内 + if (isset($target[$userField])) { + if (!in_array($target[$userField], $roleTreeUserIds)) { + throw new BusinessException("无权为指定用户创建数据", 403); + } + } else { + $target[$userField] = $currentRoleId; + } + } else { + // 更新时校验原始记录用户 + if (!in_array($target->{$userField}, $roleTreeUserIds)) { + throw new BusinessException("无权操作该记录", 403); + } + + // 校验新用户是否在允许范围内 + if (isset($newData[$userField])) { + if (!in_array($newData[$userField], $roleTreeUserIds)) { + throw new BusinessException("无权变更用户字段", 403); + } + } + } + } + + /** + * 处理从属关系权限 + */ + private static function handleRelationPermission( + &$target, + array $newData, + string $userField, + int $currentUserId + ): void { + // 获取从属关系用户ID列表 + $relationUserIds = self::getRelationUserIds($currentUserId); + + if (is_array($target)) { + // 创建时校验用户是否在允许范围内 + if (isset($target[$userField])) { + if (!in_array($target[$userField], $relationUserIds)) { + throw new BusinessException("无权为指定用户创建数据", 403); + } + } else { + $target[$userField] = $currentUserId; + } + } else { + // 更新时校验原始记录用户 + if (!in_array($target->{$userField}, $relationUserIds)) { + throw new BusinessException("无权操作该记录", 403); + } + + // 校验新用户是否在允许范围内 + if (isset($newData[$userField])) { + if (!in_array($newData[$userField], $relationUserIds)) { + throw new BusinessException("无权变更用户字段", 403); + } + } + } + } + + /** + * 获取角色树ID(当前角色及其所有子角色) + * 从数据库中动态获取角色树关系 + */ + private static function getRoleTreeIds(int $roleId): array + { + static $roleTreeCache = []; + + if (isset($roleTreeCache[$roleId])) { + return $roleTreeCache[$roleId]; + } + + // 从数据库中递归获取角色树 + $ids = [$roleId]; + $queue = [$roleId]; + + while (!empty($queue)) { + $current = array_shift($queue); + // 查找当前角色的所有子角色 + $childRoles = Db::table('roles') + ->where('parent_id', $current) + ->where('status', 1) // 只获取启用的角色 + ->pluck('id') + ->toArray(); + + foreach ($childRoles as $childId) { + if (!in_array($childId, $ids)) { + $ids[] = $childId; + $queue[] = $childId; + } + } + } + + $roleTreeCache[$roleId] = $ids; + return $ids; + } + + /** + * 检查是否为超管 + * + * @param int $userId 用户ID + * @return bool 是否为超管 + */ + public static function isSuperAdmin(int $userId): bool + { + // 检查用户是否关联了超级管理员角色 + // 设定角色ID为1的是超级管理员角色 + $superAdminRoleId = 1; + + return Db::table('user_roles') + ->where('user_id', $userId) + ->where('role_id', $superAdminRoleId) + ->exists(); + } + + /** + * 获取用户的角色ID + * + * @param int $userId 用户ID + * @return int 角色ID + */ + public static function getUserIdRoleId(int $userId): int + { + // 从 user_roles 表中获取当前用户的角色ID + $roleId = Db::table('user_roles') + ->where('user_id', $userId) + ->value('role_id'); + + return $roleId ?: 0; + } + + /** + * 获取用户的所有角色ID(包括继承的角色) + * + * @param int $userId 用户ID + * @return array 角色ID列表 + */ + public static function getUserAllRoleIds(int $userId): array + { + // 获取用户直接关联的角色 + $directRoleIds = Db::table('user_roles') + ->where('user_id', $userId) + ->pluck('role_id') + ->toArray(); + + // 获取所有继承的角色 + $allRoleIds = []; + foreach ($directRoleIds as $roleId) { + $allRoleIds = array_merge($allRoleIds, self::getRoleTreeIds($roleId)); + } + + // 去重并返回 + return array_unique($allRoleIds); + } +} \ No newline at end of file diff --git a/app/common/utils/CrudUtil_bak.php b/app/common/utils/CrudUtil_bak.php new file mode 100644 index 0000000..10617e2 --- /dev/null +++ b/app/common/utils/CrudUtil_bak.php @@ -0,0 +1,843 @@ + User::class, + * 'where' => [ + * 'status' => 1, + * 'name' => ['like','%张%'], + * 'roles.name' => '管理员', // 关联表字段过滤 + * 'posts.created_at'=> ['>','2025-01-01'], + * ], + * 'order' => ['id'=>'desc'], + * 'fields' => ['id','name','email'], + * 'with' => ['roles','posts'], + * 'withFields' => [ + * 'roles' => ['id','name'], // 关联表字段选择 + * 'posts' => ['id','title','user_id'], + * ], + * ]; + */ + public static function buildQuery(array $config): Builder + { + /** @var Model $model */ + $model = new $config['model']; + $q = $model->newQuery(); + + // eager-load:支持 ['rel','rel2'] 和 ['rel'=>fields[], ...] + if (! empty($config['with'])) { + foreach ($config['with'] as $rel => $fields) { + if (is_int($rel)) { + // 普通关联 + $q->with($fields); + } else { + // 带字段筛选的关联 + $q->with([$rel => function ($qr) use ($fields) { + $key = $qr->getModel()->getKeyName(); + if (! in_array($key, $fields, true)) { + $fields[] = $key; + } + $qr->select($fields); + }]); + } + } + } + + // 表字段 + if (!empty($config['fields'])) { + $q->select($config['fields']); + } + + // where + if (!empty($config['where'])) { + foreach ($config['where'] as $col => $val) { + if (strpos($col, '.') !== false) { + // 多层支持 + $segments = explode('.', $col); + $leaf = array_pop($segments); + self::applyNestedWhere($q, $segments, $leaf, $val); + } else { + self::applyWhere($q, $col, $val); + } + } + } + + // or where + if (!empty($config['orWhere'])) { + $q->where(function($q) use($config) { + foreach ($config['orWhere'] as $cond) { + [$col,$op,$val] = $cond; + $q->orWhere($col,$op,$val); + } + }); + } + + // 排序 + if (!empty($config['order'])) { + foreach ($config['order'] as $col => $dir) { + $q->orderBy($col, strtolower($dir) === 'asc' ? 'asc' : 'desc'); + } + } + + // 数据权限过滤 + if (!empty($config['dataLimit'])) { + self::applyDataLimit($q, $config['dataLimit']); + } + + return $q; + } + + /** + * 分页查询 + * + * @param Builder $query + * @param int $perPage + * @param int $page + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + */ + public static function paginate(Builder $q, int $perPage, int $page) + { + return $q->paginate($perPage, ['*'], 'page', $page); + } + + /** + * 非分页查询 + * + * @param Builder $query + * @return \Illuminate\Database\Eloquent\Collection + */ + public static function getAll(Builder $q) + { + return $q->get(); + } + + /** + * 执行复杂查询——仅在显式调用时生效 + * + * @param string $modelClass 模型类 + * @param callable $callback function(Builder $q) { ... } + * @return mixed 由 callback 返回的结果 + */ + public static function executeComplex(string $modelClass, callable $cb) + { + /** @var Model $model */ + $model = new $modelClass; + return $cb($model->newQuery()); + } + + /** + * 主键单条查询 + * + * @param string $modelClass + * @param mixed $id + * @param array $with + * @return Model + * @throws BusinessException + */ + public static function findByPk(string $modelClass, $id, array $with = []): Model + { + $m = $modelClass::with($with)->find($id); + if (!$m) { + throw new BusinessException("记录 ID={$id} 不存在", 404); + } + return $m; + } + + /** + * 任意条件单条查询,支持 dataLimit + * + * @param Builder $query + * @return Model + * @throws BusinessException + */ + public static function findOneBy(Builder $q): Model + { + return $q->first(); + } + + /** + * 创建,支持 dataLimit + * + * @param string $modelClass + * @param array $data + * @param array $dataLimit + * @return Model + * @throws BusinessException + */ + public static function create( + string $modelClass, + array $data, + array $dataLimit = [] + ): Model { + // dataLimit 检查/注入 + if (!empty($dataLimit)) { + self::applyDataLimitOnMutate($data, $dataLimit); + } + return $modelClass::create($data); + } + + /** + * 主键更新,支持 dataLimit + * + * @param string $modelClass + * @param mixed $id + * @param array $data + * @param array $dataLimit + * @return Model + * @throws BusinessException + */ + public static function updateByPk( + string $modelClass, + $id, + array $data, + array $dataLimit = [] + ): Model { + /** @var Model $m */ + $m = $modelClass::find($id); + if (!$m) { + throw new BusinessException("记录 ID={$id} 不存在", 404); + } + // dataLimit 校验 + if (!empty($dataLimit)) { + self::applyDataLimitOnMutate($m, $dataLimit, $data); + } + $m->fill($data)->save(); + return $m; + } + + /** + * 非主键条件更新(批量),支持 dataLimit + * + * @param Builder $query + * @param array $data + * @return int 受影响行数 + */ + public static function updateBy(Builder $q, array $data): int + { + return $q->update($data); + } + + /** + * 主键删除(支持批量) + * + * @param string $modelClass + * @param array $ids + * @return int 删除条数 + */ + public static function deleteByPk(string $modelClass, array $ids): int + { + return $modelClass::destroy($ids); + } + + /** + * 非主键条件删除,支持 dataLimit + * + * @param Builder $query + * @return int 删除条数 + */ + public static function deleteBy(Builder $q): int + { + return $q->delete(); + } + + + //=================== Internal Methods ===================// + + + /** + * 支持多层的 whereHas:$relations=['roles','permissions'],$leaf='name' + */ + protected static function applyNestedWhere(Builder $q, array $relations, string $leaf, $val): void + { + $rel = array_shift($relations); + $q->whereHas($rel, function (Builder $qr) use ($relations, $leaf, $val) { + if ($relations) { + // 还有更深层,继续递归 + self::applyNestedWhere($qr, $relations, $leaf, $val); + } else { + // 最后一层,使用普通 applyWhere + self::applyWhere($qr, $leaf, $val); + } + }); + } + + /** + * 单字段 where 支持: + * - 多条件数组 [ [op1,val1], [op2,val2], … ] + * - 单条件 ['op', val] + * - 直接值 + * + * @param Builder $q + * @param string $col + * @param mixed $val + * @return void + */ + protected static function applyWhere(Builder $q, string $col, $val): void + { + // 多条件数组 + if (is_array($val) && isset($val[0]) && is_array($val[0])) { + foreach ($val as $pair) { + if (is_array($pair) && count($pair) === 2) { + self::applySingleWhere($q, $col, $pair[0], $pair[1]); + } + } + } + // 单条件 + elseif (is_array($val) && count($val) === 2) { + self::applySingleWhere($q, $col, $val[0], $val[1]); + } + // 直接等值 + else { + $q->where($col, $val); + } + } + + /** + * 执行条件 where + */ + protected static function applySingleWhere(Builder $q, string $col, string $op, $v): void + { + switch (strtolower($op)) { + case 'like': + $q->where($col, 'like', $v); + break; + case 'in': + $q->whereIn($col, (array)$v); + break; + case 'not in': + $q->whereNotIn($col, (array)$v); + break; + case 'null': + $q->whereNull($col); + break; + case 'not null': + $q->whereNotNull($col); + break; + case 'between': + $q->whereBetween($col, (array)$v); + break; + default: + $q->where($col, $op, $v); + } + } + + + // ================== Data Limit ================== + + /** + * 应用数据权限过滤到查询 + * + * @param Builder $query 查询构造器 + * @param string $type 权限类型:superadmin, personal, same_role, role_tree, relation + * @param int $currentUserId 当前用户ID + * @param int $currentRoleId 当前角色ID + * @param string $userField 用户ID字段名 + * @param string $roleField 角色ID字段名 + */ + public static function applyQueryPermission( + Builder $query, + string $type, + int $currentUserId = 0, + int $currentRoleId = 0, + string $userField = 'user_id', + string $roleField = 'role_id' + ): void { + // 超管不需要过滤 + if (self::isSuperAdmin($currentUserId)) { + return; + } + + switch ($type) { + case 'personal': + // 个人权限:只能访问自己的数据 + $query->where($userField, $currentUserId); + break; + + case 'same_role': + // 同角色权限:访问同角色的数据 + $userIds = self::getSameRoleUserIds($currentRoleId); + $query->whereIn($userField, $userIds); + break; + + case 'role_tree': + // 角色树权限:访问自己角色及子角色的数据 + $userIds = self::getRoleTreeUserIds($currentRoleId); + $query->whereIn($userField, $userIds); + break; + + case 'relation': + // 从属关系权限:访问自己及下属的数据 + $userIds = self::getRelationUserIds($currentUserId); + $query->whereIn($userField, $userIds); + break; + + default: + // 默认个人权限 + $query->where($userField, $currentUserId); + } + } + + /** + * 应用数据权限到创建/更新操作 + * + * @param mixed $target 创建时为数组,更新时为Model对象 + * @param string $type 权限类型 + * @param int $currentUserId 当前用户ID + * @param int $currentRoleId 当前角色ID + * @param array $newData 更新时的数据 + * @param string $userField 用户ID字段名 + * @param string $roleField 角色ID字段名 + */ + public static function applyMutationPermission( + &$target, + string $type, + int $currentUserId, + int $currentRoleId, + array $newData = [], + string $userField = 'admin_id', + string $roleField = 'role_id' + ): void { + // 超管不需要检查 + if (self::isSuperAdmin($currentUserId)) { + return; + } + + switch ($type) { + case 'personal': + self::handlePersonalPermission($target, $newData, $userField, $currentUserId); + break; + + case 'same_role': + self::handleSameRolePermission($target, $newData, $userField, $roleField, $currentRoleId); + break; + + case 'role_tree': + self::handleRoleTreePermission($target, $newData, $userField, $roleField, $currentRoleId); + break; + + case 'relation': + self::handleRelationPermission($target, $newData, $userField, $currentUserId); + break; + + default: + throw new BusinessException("未定义的数据权限类型: $type", 403); + } + } + + /** + * 获取同角色的用户ID列表 + * + * @param int $roleId 角色ID + * @return array 用户ID列表 + */ + public static function getSameRoleUserIds(int $roleId): array + { + return Db::table('user_roles') + ->where('role_id', $roleId) + ->pluck('user_id') + ->toArray(); + } + + /** + * 获取角色树下的所有用户ID列表 + * + * @param int $roleId 当前角色ID + * @return array 用户ID列表 + */ + public static function getRoleTreeUserIds(int $roleId): array + { + // 获取角色树中的所有角色ID + $roleIds = self::getRoleTreeIds($roleId); + + // 获取这些角色下的所有用户ID + return Db::table('user_roles') + ->whereIn('role_id', $roleIds) + ->pluck('user_id') + ->toArray(); + } + + /** + * 获取从属关系下的用户ID列表(包括自己和下属) + * + * @param int $userId 当前用户ID + * @return array 用户ID列表 + */ + public static function getRelationUserIds(int $userId): array + { + // 获取所有下属用户ID(递归) + $subordinateIds = self::getSubordinateIds($userId); + + // 返回自己和所有下属的ID + return array_merge([$userId], $subordinateIds); + } + + /** + * 获取直接下属的用户ID列表 + * + * @param int $userId 用户ID + * @return array 直接下属用户ID列表 + */ + public static function getDirectSubordinateIds(int $userId): array + { + return Db::table('user') + ->where('report_to', $userId) + ->pluck('id') + ->toArray(); + } + + /** + * 递归获取所有下属用户ID列表 + * + * @param int $userId 用户ID + * @return array 所有下属用户ID列表 + */ + private static function getSubordinateIds(int $userId): array + { + $subordinates = self::getDirectSubordinateIds($userId); + $allSubordinates = $subordinates; + + foreach ($subordinates as $subordinateId) { + $allSubordinates = array_merge($allSubordinates, self::getSubordinateIds($subordinateId)); + } + + return $allSubordinates; + } + + /** + * 处理个人权限 + */ + private static function handlePersonalPermission( + &$target, + array $newData, + string $userField, + int $currentUserId + ): void { + if (is_array($target)) { + // 创建时强制设置用户ID + $target[$userField] = $currentUserId; + } else { + // 更新时校验原始记录 + if ($target->{$userField} !== $currentUserId) { + throw new BusinessException("无权操作该记录", 403); + } + + // 禁止变更归属字段 + if (isset($newData[$userField]) && $newData[$userField] !== $currentUserId) { + throw new BusinessException("无权变更归属字段", 403); + } + } + } + + /** + * 处理同角色权限 + */ + private static function handleSameRolePermission( + &$target, + array $newData, + string $userField, + string $roleField, + int $currentRoleId + ): void { + // 获取同角色用户ID列表 + $sameRoleUserIds = self::getSameRoleUserIds($currentRoleId); + + if (is_array($target)) { + // 创建时设置归属用户并校验 + if (isset($target[$userField])) { + if (!in_array($target[$userField], $sameRoleUserIds)) { + throw new BusinessException("无权为指定用户创建数据", 403); + } + } else { + $target[$userField] = $currentRoleId; + } + } else { + // 更新时校验原始记录 + if (!in_array($target->{$userField}, $sameRoleUserIds)) { + throw new BusinessException("无权操作该记录", 403); + } + + // 禁止变更归属字段到无权限的用户 + if (isset($newData[$userField]) && !in_array($newData[$userField], $sameRoleUserIds)) { + throw new BusinessException("无权变更归属字段", 403); + } + } + } + + /** + * 处理角色树权限 + */ + private static function handleRoleTreePermission( + &$target, + array $newData, + string $userField, + string $roleField, + int $currentRoleId + ): void { + // 获取角色树用户ID列表 + $roleTreeUserIds = self::getRoleTreeUserIds($currentRoleId); + + if (is_array($target)) { + // 创建时校验用户是否在允许范围内 + if (isset($target[$userField])) { + if (!in_array($target[$userField], $roleTreeUserIds)) { + throw new BusinessException("无权为指定用户创建数据", 403); + } + } else { + $target[$userField] = $currentRoleId; + } + } else { + // 更新时校验原始记录用户 + if (!in_array($target->{$userField}, $roleTreeUserIds)) { + throw new BusinessException("无权操作该记录", 403); + } + + // 校验新用户是否在允许范围内 + if (isset($newData[$userField])) { + if (!in_array($newData[$userField], $roleTreeUserIds)) { + throw new BusinessException("无权变更用户字段", 403); + } + } + } + } + + /** + * 处理从属关系权限 + */ + private static function handleRelationPermission( + &$target, + array $newData, + string $userField, + int $currentUserId + ): void { + // 获取从属关系用户ID列表 + $relationUserIds = self::getRelationUserIds($currentUserId); + + if (is_array($target)) { + // 创建时校验用户是否在允许范围内 + if (isset($target[$userField])) { + if (!in_array($target[$userField], $relationUserIds)) { + throw new BusinessException("无权为指定用户创建数据", 403); + } + } else { + $target[$userField] = $currentUserId; + } + } else { + // 更新时校验原始记录用户 + if (!in_array($target->{$userField}, $relationUserIds)) { + throw new BusinessException("无权操作该记录", 403); + } + + // 校验新用户是否在允许范围内 + if (isset($newData[$userField])) { + if (!in_array($newData[$userField], $relationUserIds)) { + throw new BusinessException("无权变更用户字段", 403); + } + } + } + } + + /** + * 获取角色树ID(当前角色及其所有子角色) + * 从数据库中动态获取角色树关系 + */ + private static function getRoleTreeIds(int $roleId): array + { + static $roleTreeCache = []; + + if (isset($roleTreeCache[$roleId])) { + return $roleTreeCache[$roleId]; + } + + // 从数据库中递归获取角色树 + $ids = [$roleId]; + $queue = [$roleId]; + + while (!empty($queue)) { + $current = array_shift($queue); + // 查找当前角色的所有子角色 + $childRoles = Db::table('roles') + ->where('parent_id', $current) + ->where('status', 1) // 只获取启用的角色 + ->pluck('id') + ->toArray(); + + foreach ($childRoles as $childId) { + if (!in_array($childId, $ids)) { + $ids[] = $childId; + $queue[] = $childId; + } + } + } + + $roleTreeCache[$roleId] = $ids; + return $ids; + } + + /** + * 检查是否为超管 + * + * @param int $userId 用户ID + * @return bool 是否为超管 + */ + public static function isSuperAdmin(int $userId): bool + { + // 检查用户是否关联了超级管理员角色 + // 假设角色ID为1的是超级管理员角色 + $superAdminRoleId = 1; + + return Db::table('user_roles') + ->where('user_id', $userId) + ->where('role_id', $superAdminRoleId) + ->exists(); + } + + /** + * 获取用户的角色ID + * + * @param int $userId 用户ID + * @return int 角色ID + */ + public static function getUserIdRoleId(int $userId): int + { + // 从 user_roles 表中获取当前用户的角色ID + $roleId = Db::table('user_roles') + ->where('user_id', $userId) + ->value('role_id'); + + return $roleId ?: 0; + } + + /** + * 获取用户的所有角色ID(包括继承的角色) + * + * @param int $userId 用户ID + * @return array 角色ID列表 + */ + public static function getUserAllRoleIds(int $userId): array + { + // 获取用户直接关联的角色 + $directRoleIds = Db::table('user_roles') + ->where('user_id', $userId) + ->pluck('role_id') + ->toArray(); + + // 获取所有继承的角色 + $allRoleIds = []; + foreach ($directRoleIds as $roleId) { + $allRoleIds = array_merge($allRoleIds, self::getRoleTreeIds($roleId)); + } + + // 去重并返回 + return array_unique($allRoleIds); + } + + // ================== Data Limit ================== + + /** + * 应用数据权限过滤到查询(buildQuery 场景) + */ + protected static function applyDataLimit(Builder $q, array $dataLimit): void + { + if (Auth::isSuperAdmin()) { + return; + } + $type = $dataLimit['type'] ?? null; + $field = $dataLimit['field'] ?? 'admin_id'; + + if ($type === 'personal') { + $q->where($field, admin_id()); + } elseif ($type === 'auth') { + $q->whereIn($field, Auth::getScopeAdminIds(true)); + } + } + + /** + * 应用数据权限到“创建/更新”操作 + * + * - $target 可能是 array|Model 对象 + * - $newData 仅在 updateByPk 场景时传入,用以校验变更归属 + */ + protected static function applyDataLimitOnMutate(&$target, array $dataLimit, array $newData = []): void + { + if (Auth::isSuperAdmin()) { + return; + } + $type = $dataLimit['type'] ?? null; + $field = $dataLimit['field'] ?? 'admin_id'; + + // 读取当前归属:create 时 $target 是 array,update 时是 Model + $current = is_array($target) ? null : $target->{$field}; + + // 1. personal → 只能自己 + if ($type === 'personal') { + // create: 强制设置 + if (is_array($target)) { + $target[$field] = admin_id(); + } + // update: 校验当前归属 + elseif ($current !== admin_id()) { + throw new BusinessException("无权操作该记录", 403); + } + // 若要修改归属,也必须和自己一致 + if (isset($newData[$field]) && $newData[$field] !== admin_id()) { + throw new BusinessException("无权变更归属字段", 403); + } + } + + // 2. auth → 自己及下属 + if ($type === 'auth') { + $scope = Auth::getScopeAdminIds(true); + // create: 若前端指定归属,则校验范围;否则默认自己 + if (is_array($target)) { + if (isset($target[$field])) { + if (!in_array($target[$field], $scope)) { + throw new BusinessException("无权为指定用户操作", 403); + } + } else { + $target[$field] = admin_id(); + } + } + // update: 校验当前归属 + elseif (!in_array($current, $scope)) { + throw new BusinessException("无权操作该记录", 403); + } + // 校验更新中指定的新归属 + if (isset($newData[$field]) && !in_array($newData[$field], $scope)) { + throw new BusinessException("无权变更归属字段", 403); + } + } + } +} \ No newline at end of file diff --git a/app/common/utils/Feishu.php b/app/common/utils/Feishu.php new file mode 100644 index 0000000..bb4f98d --- /dev/null +++ b/app/common/utils/Feishu.php @@ -0,0 +1,229 @@ +baseUri = 'https://open.larksuite.com/open-apis/'; + } else { + $this->baseUri = 'https://open.feishu.cn/open-apis/'; + } + + $this->appId = $appId; + $this->appSecret = $appSecret; + $this->abbr = $abbr; + + $this->client = new Client([ + 'base_uri' => $this->baseUri, + 'verify' => false, // 禁用SSL验证 + ]); + + $this->options = [ + 'headers' => [ + 'Content-Type' => 'application/json; charset=utf-8' + ] + ]; + } + + /** + * 异常 + */ + public function error() + { + return $this->error; + } + + /** + * 请求 + */ + public function requestResult($url = "", $method = "POST") + { + try { + $res = $this->client->request($method, $this->baseUri.$url, $this->options); + if ($res->getBody()) { + $result = json_decode($res->getBody(), true); + // var_dump($result); + if ($result["code"] === 0) { + return $result; + } else { + $this->error = $result["msg"]; + return false; + } + } else { + $this->error = "result error"; + return false; + } + } catch (RequestException $e) { + if ($e->hasResponse()) { + var_dump('2 :'.$e->getResponse()->getBody()); + throw new BusinessException($e->getResponse()->getBody()); + } else { + var_dump('3 :'.$e->getMessage()); + throw new BusinessException($e->getMessage()); + } + } catch (\Throwable $th) { + var_dump('4 :'.$th->getFile()."|".$th->getLine()."|".$th->getMessage()); + $this->error = $th->getFile()."|".$th->getLine()."|".$th->getMessage(); + return false; + } + } + + /** + * 授权 + */ + public function authorize($certificate,$mobile, $url = 'authen/v1/authorize') + { + $arr = [ + 'app_id' => $certificate['app_id'], + 'redirect_uri' => $certificate['r_url'], + 'scope' => $certificate['scope'], + 'state' => $certificate['state'] + ]; + $queryData = http_build_query($arr); + return $this->baseUri.$url.'?'.$queryData; + } + + /** + * 自建应用获取 tenant_access_token + */ + public function tenantAccessToken() + { + $tenantAccessToken = Cache::get($this->abbr.'-tenantAccessToken'); + if (!$tenantAccessToken) { + $this->options["json"] = [ + "app_id" => $this->appId, + "app_secret" => $this->appSecret + ]; + $rst = $this->requestResult('auth/v3/tenant_access_token/internal', 'POST'); + if ($rst) { + $tenantAccessToken = $rst['tenant_access_token']; + Cache::set($this->abbr.'-tenantAccessToken', $tenantAccessToken, $rst['expire']); + } else { + throw new BusinessException('tenant_access_token 获取失败'); + } + } + return $tenantAccessToken; + } + + /** + * 通过手机号或邮箱获取用户 ID + */ + public function getUserIdByPhoneOrEmail($phone = '', $userIdType = 'open_id') + { + $this->options["headers"] = [ + "Authorization" => "Bearer " . $this->tenantAccessToken(), + "Content-Type" => "application/json; charset=utf-8" + ]; + $this->options["json"] = [ + "emails" => [], + "mobiles" => [$phone], + "include_resigned" => false + ]; + $rst = $this->requestResult('contact/v3/users/batch_get_id?user_id_type='.$userIdType, 'POST'); + return $rst; + } + + /** + * 发送消息 + */ + public function sendMessages($receiveId = '', $content = '', $msgType = 'text', $receiveIdtype = 'open_id') + { + $this->options["headers"] = [ + "Authorization" => "Bearer " . $this->tenantAccessToken(), + "Content-Type" => "application/json; charset=utf-8" + ]; + $this->options["json"] = [ + "receive_id" => $receiveId, + "msg_type" => $msgType, + "content" => $content + ]; + $rst = $this->requestResult('im/v1/messages?receive_id_type='.$receiveIdtype, 'POST'); + return $rst; + } + + /** + * 编辑消息 + */ + public function updateSendMessages($messageId = '', $content = '', $msgType = 'text') + { + $this->options["headers"] = [ + "Authorization" => "Bearer " . $this->tenantAccessToken(), + "Content-Type" => "application/json; charset=utf-8" + ]; + $this->options["json"] = [ + "msg_type" => $msgType, + "content" => $content + ]; + $rst = $this->requestResult('im/v1/messages/'.$messageId, 'PUT'); + return $rst; + } + + /** + * 用户 user_access_token + */ + public function userAccessToken($code = "", $redirect_uri = "") + { + $this->options["headers"] = [ + "Content-Type" => "application/json; charset=utf-8" + ]; + $this->options["json"] = [ + "grant_type" => "authorization_code", + "client_id" => $this->appId, + "client_secret" => $this->appSecret, + "code" => $code, + "redirect_uri" => $redirect_uri + ]; + return $this->requestResult('authen/v2/oauth/token'); + } + + /** + * 用户信息 + */ + public function userInfo($userAccessToken = "") + { + $this->options["headers"] = [ + "Authorization" => "Bearer " . $userAccessToken, + "Content-Type" => "application/json; charset=utf-8" + ]; + return $this->requestResult('authen/v1/user_info', "GET"); + } + + // public function user_access_token($code = "") + // { + // $url = ""; + // $this->options["headers"] = [ + // "Authorization" => "Bearer ".$this->tenantAccessToken(), + // "Content-Type" => "application/json; charset=utf-8" + // ]; + // $this->options["json"] = [ + // "grant_type" => "authorization_code", + // "code" => $code + // ]; + // $this->options["form_params"] = [ + // "app_id" => $this->app_id, + // "app_secret" => $this->app_secret + // ]; + // return $this->requestResult($url); + // } +} \ No newline at end of file diff --git a/app/common/utils/FeishuMessage.php b/app/common/utils/FeishuMessage.php new file mode 100644 index 0000000..4783a9c --- /dev/null +++ b/app/common/utils/FeishuMessage.php @@ -0,0 +1,565 @@ +app_id = $options['app_id']; + } + if (isset($options['app_secret']) && $options['app_secret']) { + $this->app_secret = $options['app_secret']; + } + if (isset($options['state']) && $options['state']) { + $this->state = $options['state']; + } + $this->class = new Feishu($this->app_id, $this->app_secret, $this->state); + } + + /** + * 飞书消息模板 + * @param string $scene + * @param array $options + */ + public function messageTemplates(string $scene = 'group-send', array $options = []) + { + try { + $template = match ($scene) { + 'inquiry-group-send' => $this->groupNewTemplates( + $options['quality'], + $options['order_no'], + $options['channel'], + $options['country'], + $options['enter_user'], + $options['r_time'] + ), + 'inquiry-group-update' => $this->groupUpdateTemplates( + $options['quality'], + $options['order_no'], + $options['channel'], + $options['country'], + $options['enter_user'], + $options['r_time'], + $options['t_time'] + ), + 'inquiry-person-allocation' => $this->personAllocationTemplates( + $options['quality'], + $options['order_no'], + $options['channel'], + $options['country'], + $options['enter_user'], + $options['r_time'], + $options['t_time'] + ), + 'crm-person-follow-up' => $this->personFollowUpTemplates( + $options['contacts'], + $options['follow_next'], + $options['follow_last'] + ) + }; + } catch (\UnhandledMatchError $e) { + throw new BusinessException($e->getMessage()); + } + return $template; + } + + /** + * 发送飞书消息 + * @param array $options + */ + public function messageSend(string $scene = 'group-send', array $options = []) + { + $template = $this->messageTemplates($scene, $options); + $result = $this->class->sendMessages($options['receive_id'], $template, $options['msg_type'], $options['receive_id_type']); + if (!isset($result['code']) || $result['code'] !== 0) { + throw new BusinessException('发送飞书消息失败'); + } + return $result; + } + + /** + * 线索消息发送 + */ + public function groupNewTemplates($quality = '💛 银牌商机', $order_no = 'SN2025072365640', $channel = '谷歌', $country = '白俄罗斯', $enter_user = '木子李', $r_time = '2025-07-24 09:41:23') + { + $content = '{ + "schema": "2.0", + "config": { + "update_multi": true, + "style": { + "text_size": { + "normal_v2": { + "default": "normal", + "pc": "normal", + "mobile": "heading" + } + } + } + }, + "body": { + "direction": "vertical", + "horizontal_spacing": "8px", + "vertical_spacing": "16px", + "horizontal_align": "left", + "vertical_align": "top", + "padding": "0px 20px 20px 20px", + "elements": [ + { + "tag": "hr", + "margin": "0px 0px 0px 0px" + }, + { + "tag": "column_set", + "horizontal_align": "left", + "columns": [ + { + "tag": "column", + "width": "weighted", + "elements": [ + { + "tag": "markdown", + "content": " **'.$quality.'**\n- **编号:** '.$order_no.'\n- **渠道:** '.$channel.'\n- **国家:** '.$country.'\n- **发布人:** '.$enter_user.'\n- **发布时间:** '.$r_time.'", + "text_align": "left", + "text_size": "normal_v2", + "margin": "0px 0px 0px 0px" + } + ], + "vertical_spacing": "8px", + "horizontal_align": "left", + "vertical_align": "top", + "weight": 1 + } + ] + }, + { + "tag": "form", + "elements": [ + { + "tag": "hr", + "margin": "0px 0px 0px 0px" + }, + { + "tag": "column_set", + "flex_mode": "stretch", + "horizontal_spacing": "8px", + "horizontal_align": "left", + "columns": [ + { + "tag": "column", + "width": "auto", + "elements": [ + { + "tag": "button", + "text": { + "tag": "plain_text", + "content": "立即跟踪" + }, + "type": "primary_filled", + "width": "fill", + "size": "medium", + "icon": { + "tag": "standard_icon", + "token": "list-check-bold_outlined" + }, + "confirm": { + "title": { + "tag": "plain_text", + "content": "确认跟踪?" + }, + "text": { + "tag": "plain_text", + "content": "客户线索:'.$order_no.'" + } + }, + "behaviors": [ + { + "type": "callback", + "value": "'.$order_no.'" + } + ], + "form_action_type": "submit", + "name": "Button_m7d0zqa2", + "element_id": "track" + } + ], + "direction": "vertical", + "horizontal_spacing": "8px", + "vertical_spacing": "8px", + "horizontal_align": "left", + "vertical_align": "top" + }, + { + "tag": "column", + "width": "auto", + "elements": [], + "direction": "vertical", + "horizontal_spacing": "8px", + "vertical_spacing": "8px", + "horizontal_align": "left", + "vertical_align": "top" + }, + { + "tag": "column", + "width": "auto", + "elements": [], + "padding": "0px 0px 0px 0px", + "direction": "vertical", + "horizontal_spacing": "8px", + "vertical_spacing": "8px", + "horizontal_align": "left", + "vertical_align": "top", + "margin": "0px 0px 0px 0px" + } + ], + "margin": "4px 0px 0px 0px" + } + ], + "direction": "vertical", + "horizontal_spacing": "8px", + "vertical_spacing": "12px", + "horizontal_align": "left", + "vertical_align": "top", + "padding": "0px 0px 0px 0px", + "margin": "0px 0px 0px 0px", + "name": "Form_m7d0zqa1" + } + ] + }, + "header": { + "title": { + "tag": "plain_text", + "content": "新的【公海】客户线索" + }, + "subtitle": { + "tag": "plain_text", + "content": "" + }, + "template": "default", + "icon": { + "tag": "standard_icon", + "token": "ai-common_colorful" + }, + "padding": "12px 12px 12px 20px" + } + }'; + return $content; + } + + /** + * 线索消息更新 + */ + public function groupUpdateTemplates($quality = '💛 银牌商机', $order_no = 'SN2025072365640', $channel = '谷歌', $country = '白俄罗斯', $enter_user = '木子李', $r_time = '2025-07-24 09:41:23', $t_time = '2025-07-24 14:41:23') + { + $content = '{ + "schema": "2.0", + "config": { + "update_multi": true, + "style": { + "text_size": { + "normal_v2": { + "default": "normal", + "pc": "normal", + "mobile": "heading" + } + } + } + }, + "body": { + "direction": "vertical", + "horizontal_spacing": "8px", + "vertical_spacing": "16px", + "horizontal_align": "left", + "vertical_align": "top", + "padding": "0px 20px 20px 20px", + "elements": [ + { + "tag": "hr", + "margin": "0px 0px 0px 0px" + }, + { + "tag": "column_set", + "horizontal_align": "left", + "columns": [ + { + "tag": "column", + "width": "weighted", + "elements": [ + { + "tag": "markdown", + "content": " **'.$quality.'**\n- **编号:** '.$order_no.'\n- **渠道:** '.$channel.'\n- **国家:** '.$country.'\n- **发布人:** '.$enter_user.'\n- **发布时间:** '.$r_time.'", + "text_align": "left", + "text_size": "normal_v2", + "margin": "0px 0px 0px 0px" + } + ], + "vertical_spacing": "8px", + "horizontal_align": "left", + "vertical_align": "top", + "weight": 1 + } + ] + }, + { + "tag": "hr", + "margin": "0px 0px 0px 0px" + }, + { + "tag": "div", + "text": { + "tag": "plain_text", + "content": "跟踪时间:'.$t_time.'", + "text_size": "notation", + "text_align": "left", + "text_color": "green" + }, + "icon": { + "tag": "standard_icon", + "token": "chat-done_outlined", + "color": "green" + }, + "element_id": "track_time" + } + ] + }, + "header": { + "title": { + "tag": "plain_text", + "content": "【公海】客户线索" + }, + "subtitle": { + "tag": "plain_text", + "content": "" + }, + "text_tag_list": [ + { + "tag": "text_tag", + "text": { + "tag": "plain_text", + "content": "已跟踪" + }, + "color": "orange" + } + ], + "template": "green", + "icon": { + "tag": "standard_icon", + "token": "ai-common_colorful" + }, + "padding": "12px 12px 12px 20px" + } + }'; + return $content; + } + + /** + * 线索分配消息 + */ + public function personAllocationTemplates($quality = '💛 银牌商机', $order_no = 'SN2025072365640', $channel = '谷歌', $country = '白俄罗斯', $enter_user = '木子李', $r_time = '2025-07-24 09:41:23', $t_time = '2025-07-24 14:41:23') + { + $content = '{ + "schema": "2.0", + "config": { + "update_multi": true, + "style": { + "text_size": { + "normal_v2": { + "default": "normal", + "pc": "normal", + "mobile": "heading" + } + } + } + }, + "body": { + "direction": "vertical", + "horizontal_spacing": "8px", + "vertical_spacing": "16px", + "horizontal_align": "left", + "vertical_align": "top", + "padding": "0px 20px 20px 20px", + "elements": [ + { + "tag": "hr", + "margin": "0px 0px 0px 0px" + }, + { + "tag": "column_set", + "horizontal_align": "left", + "columns": [ + { + "tag": "column", + "width": "weighted", + "elements": [ + { + "tag": "markdown", + "content": " **'.$quality.'**\n- **编号:** '.$order_no.'\n- **渠道:** '.$channel.'\n- **国家:** '.$country.'\n- **录单人:** '.$enter_user.'\n- **录入时间:** '.$r_time.'", + "text_align": "left", + "text_size": "normal_v2", + "margin": "0px 0px 0px 0px" + } + ], + "vertical_spacing": "8px", + "horizontal_align": "left", + "vertical_align": "top", + "weight": 1 + } + ] + }, + { + "tag": "hr", + "margin": "0px 0px 0px 0px" + }, + { + "tag": "div", + "text": { + "tag": "plain_text", + "content": "分配时间:'.$t_time.'", + "text_size": "notation", + "text_align": "left", + "text_color": "green" + }, + "icon": { + "tag": "standard_icon", + "token": "chat-done_outlined", + "color": "green" + }, + "element_id": "allocation_time" + } + ] + }, + "header": { + "title": { + "tag": "plain_text", + "content": "新的客户线索" + }, + "subtitle": { + "tag": "plain_text", + "content": "" + }, + "text_tag_list": [ + { + "tag": "text_tag", + "text": { + "tag": "plain_text", + "content": "指定" + }, + "color": "yellow" + } + ], + "template": "blue", + "icon": { + "tag": "standard_icon", + "token": "todo_colorful" + }, + "padding": "12px 12px 12px 20px" + } + }'; + return $content; + } + + /** + * 跟进提醒消息 + */ + public function personFollowUpTemplates($contacts = '木子李', $follow_next = '2025-07-24 14:41:23', $follow_last = '2025-07-24 14:41:23') + { + $content = '{ + "schema": "2.0", + "config": { + "update_multi": true, + "style": { + "text_size": { + "normal_v2": { + "default": "normal", + "pc": "normal", + "mobile": "heading" + } + } + } + }, + "body": { + "direction": "vertical", + "horizontal_spacing": "8px", + "vertical_spacing": "16px", + "horizontal_align": "left", + "vertical_align": "top", + "padding": "0px 20px 20px 20px", + "elements": [ + { + "tag": "hr", + "margin": "0px 0px 0px 0px" + }, + { + "tag": "column_set", + "horizontal_align": "left", + "columns": [ + { + "tag": "column", + "width": "weighted", + "elements": [ + { + "tag": "markdown", + "content": "- **客户名称:** '.$contacts.'\n- **跟进时间:** '.$follow_next.'", + "text_align": "left", + "text_size": "normal_v2", + "margin": "0px 0px 0px 0px" + } + ], + "vertical_spacing": "8px", + "horizontal_align": "left", + "vertical_align": "top", + "weight": 1 + } + ] + }, + { + "tag": "hr", + "margin": "0px 0px 0px 0px" + }, + { + "tag": "div", + "text": { + "tag": "plain_text", + "content": "最近跟进:'.$follow_last.'", + "text_size": "notation", + "text_align": "left", + "text_color": "grey" + }, + "icon": { + "tag": "standard_icon", + "token": "chat_outlined", + "color": "grey" + }, + "element_id": "track_time" + } + ] + }, + "header": { + "title": { + "tag": "plain_text", + "content": "跟进提醒" + }, + "subtitle": { + "tag": "plain_text", + "content": "" + }, + "template": "green", + "icon": { + "tag": "standard_icon", + "token": "calendar_colorful" + }, + "padding": "12px 12px 12px 20px" + } + }'; + return $content; + } +} \ No newline at end of file diff --git a/app/common/utils/Jwt.php b/app/common/utils/Jwt.php new file mode 100644 index 0000000..c270e0d --- /dev/null +++ b/app/common/utils/Jwt.php @@ -0,0 +1,108 @@ +secretKey = config('jwt.jwt_secret_key'); + $this->algorithm = config('jwt.jwt_algorithm'); + $this->issuer = config('jwt.jwt_issuer'); + $this->expiration = config('jwt.jwt_expiration'); + } + + /** + * 生成 JWT 令牌 + * + * @param array $payload + * @return string + * @throws BusinessException + */ + public function generateToken(array $payload): string + { + $defaultPayload = [ + // // —— 注册 Claim —— + 'iss' => $this->issuer, // 签发者 + // 'sub' => 'user_1001', // 面向的用户 + // 'aud' => 'api.gateway.example.com', // 接收方 + // 'iat' => time(), // 签发时间 + // 'nbf' => time(), // 生效时间 + // 'exp' => time() + $this->expiration, // 1 小时后过期 + 'jti' => bin2hex(random_bytes(16)), // 唯一 ID + // // —— 自定义 Claim —— + // 'uid' => 1001, // 业务用户 ID + // 'roles' => ['admin', 'editor'], // 用户角色 + // 'meta' => ['region' => 'CN', 'tier' => 5], // 任意自定义数据 + ]; + + $payload = array_merge($defaultPayload, $payload); + + try { + return FirebaseJWT::encode($payload, $this->secretKey, $this->algorithm); + } catch (\Exception $e) { + throw new BusinessException("令牌生成失败:" . $e->getMessage(), 500); + } + } + + /** + * 验证 JWT 令牌 + * + * @param string $token + * @return object + * @throws BusinessException + */ + public function verifyToken(string $token) + { + try { + return FirebaseJWT::decode($token, new Key($this->secretKey, $this->algorithm)); + } catch (\Firebase\JWT\ExpiredException $e) { + throw new BusinessException('令牌已过期', 401); + } catch (\Firebase\JWT\SignatureInvalidException $e) { + throw new BusinessException('无效的令牌签名', 401); + } catch (\Firebase\JWT\BeforeValidException $e) { + throw new BusinessException('令牌尚未生效', 401); + } catch (\Exception $e) { + throw new BusinessException('令牌验证失败:' . $e->getMessage(), 400); + } + } + + + public function describe() + { + /* payload + 1. 注册 Claim 详解 + + Claim 名称 含义 类型 建议取值 + iss Issuer,发行者,标识该 JWT 的签发主体 string or URI 你的服务域名或项目标识,比如 https://api.example.com + sub Subject,主题,标识该 JWT 所面向的用户 string or int 用户在你系统里的唯一 ID + aud Audience,受众,标识该 JWT 的接收方 string or array 接收该令牌的服务标识,比如 ["service-a","service-b"] + exp Expiration Time,过期时间,Unix 时间戳,到达此时间后不可用 int time() + 3600(1 小时后过期) + nbf Not Before,生效时间,Unix 时间戳,表示在此时间之前不可用 int time() 或者 time() + 60(1 分钟后生效) + iat Issued At,签发时间,Unix 时间戳 int time() + jti JWT ID,JWT 的唯一标识符,用于去重或一次性令牌 string uniqid('', true) 或者 UUID + + 注意:exp、nbf、iat 必须是整数(秒级时间戳),否则库会抛类型异常或验证失败。 + + 2. 自定义 Claim + + 除了上面这些“注册”字段,你可以任意添加,比如: + + php + $payload['uid'] = 123; // 业务用户 ID + $payload['roles'] = ['admin','user']; // 权限数组 + $payload['feature'] = 'beta'; // 标记某个功能开关 + + 这些字段在你的应用程序里可以自由取用。 + */ + } +} \ No newline at end of file diff --git a/app/common/utils/MigrationResources.php b/app/common/utils/MigrationResources.php new file mode 100644 index 0000000..ce2f473 --- /dev/null +++ b/app/common/utils/MigrationResources.php @@ -0,0 +1,194 @@ +where($conditions); + + // 按排序字段排序 + $resources = $query->orderBy('sort')->orderBy('id')->get(); + // var_dump($resources->count()); + // 转换为数组 + $resourceList = []; + foreach ($resources as $resource) { + $resourceList[] = (array) $resource; + } + + // 构建树状结构 + return $this->buildTree($resourceList); + } + + /** + * 构建树状结构 + * + * @param array $resources 资源列表 + * @param int $parentId 父级ID + * @return array + */ + private function buildTree(array $resources, int $parentId = 0): array + { + $tree = []; + + foreach ($resources as $resource) { + if ($resource['parent_id'] == $parentId) { + // 递归获取子节点 + $children = $this->buildTree($resources, $resource['id']); + + // 如果有子节点,则添加到children字段 + if (!empty($children)) { + $resource['children'] = $children; + } + + $tree[] = $resource; + } + } + + return $tree; + } + + /** + * 使用引用方式构建树(性能优化版) + * + * @param array $conditions 筛选条件 + * @return array + */ + public function getFullResourceTreeOptimized($table = 'resources', array $conditions = []): array + { + // 查询所有资源数据 + $query = Db::table($table)->where($conditions); + + // 按排序字段排序 + $resources = $query->orderBy('sort')->orderBy('id')->get(); + + // 构建ID映射和引用关系 + $resourceMap = []; + $tree = []; + + // 初始化所有节点 + foreach ($resources as $resource) { + $resourceArray = (array) $resource; + $resourceMap[$resourceArray['id']] = $resourceArray; + } + + // 构建父子关系 + foreach ($resourceMap as $id => &$node) { + if ($node['parent_id'] == 0) { + // 根节点 + $tree[] = &$node; + } else { + // 子节点 + if (isset($resourceMap[$node['parent_id']])) { + if (!isset($resourceMap[$node['parent_id']]['children'])) { + $resourceMap[$node['parent_id']]['children'] = []; + } + $resourceMap[$node['parent_id']]['children'][] = &$node; + } else { + // 父节点不存在,作为根节点处理 + $tree[] = &$node; + } + } + } + unset($node); + + return $tree; + } + + /** + * 获取扁平化的资源列表 + * + * @param array $conditions 筛选条件 + * @return array + */ + public function getResourceList($table = 'resources', array $conditions = []): array + { + $query = Db::table($table)->where($conditions); + + // 按排序字段排序 + $resources = $query->orderBy('sort')->orderBy('id')->get(); + + // 转换为数组 + $resourceList = []; + foreach ($resources as $resource) { + $resourceList[] = (array) $resource; + } + + return $resourceList; + } + + + /** + * 递归插入资源树 + */ + private function insertResourceTree($table, $tree, $parentId, $keys = []) + { + $keys = ['system_id','name','desc','type','code','parent_id','url','icon','open_type','link', + 'is_hide','is_hide_tab','no_auth','is_home','instance','keep_alive','sort','status']; + foreach ($tree as $item) { + // 保存子节点 + $children = $item['children'] ?? []; + + // 过滤字段 + $filteredItem = []; + foreach ($item as $k => $v) { + if (in_array($k, $keys)) { + $filteredItem[$k] = $v; + } + } + + // 设置parent_id + $filteredItem['parent_id'] = $parentId; + + // 插入并获取新ID + $newId = Db::table($table)->insertGetId($filteredItem); + echo "插入节点: {$filteredItem['name']}, 新ID: $newId\n"; + + // 递归处理子节点 + if (!empty($children)) { + $this->insertResourceTree($table, $children, $newId, $keys); + } + } + } + + + public function do(string $from = 'resources_20250916_bak', string $to = 'resources_yuanshi', array $where = []) + { + try { + // 开启事务 + Db::beginTransaction(); + + // 查询迁移资源的树状结果集 + $list = $this->getFullResourceTreeOptimized($from, $where); + + // 入库字段 + $keys = ['system_id','name','desc','type','code','parent_id','url','icon','open_type','link', + 'is_hide','is_hide_tab','no_auth','is_home','instance','keep_alive','sort','status']; + + // 使用递归处理树结构 + $this->insertResourceTree($to, $list, 0, $keys); + + // 提交事务 + Db::commit(); + + echo "数据插入成功"; + + } catch (\Exception $e) { + // 回滚事务 + Db::rollBack(); + + echo "数据插入失败: " . $e->getMessage() . " 在文件 " . $e->getFile() . " 第 " . $e->getLine() . " 行"; + } + } +} \ No newline at end of file diff --git a/app/common/utils/OauthClients.php b/app/common/utils/OauthClients.php new file mode 100644 index 0000000..3ee5571 --- /dev/null +++ b/app/common/utils/OauthClients.php @@ -0,0 +1,27 @@ + string, 'client_secret' => string] + */ + public function generateClientCredentials(int $idBytes = 16, int $secretBytes = 32): array + { + // 生成随机字节,并转为十六进制字符串 + $clientId = bin2hex(random_bytes($idBytes)); + $clientSecret = bin2hex(random_bytes($secretBytes)); + + return [ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + ]; + } + +} \ No newline at end of file diff --git a/app/common/utils/RedisUidRotator.php b/app/common/utils/RedisUidRotator.php new file mode 100644 index 0000000..44d4265 --- /dev/null +++ b/app/common/utils/RedisUidRotator.php @@ -0,0 +1,161 @@ +key = "uid_rotator:{$rotatorName}:index"; + $this->setKey = "uid_rotator:{$rotatorName}:uids"; + $this->lockKey = "uid_rotator:{$rotatorName}:lock"; + $this->expireSeconds = $expireSeconds; + } + + public function setUids(array $uids, bool $resetIndex = true): bool + { + // 删除旧数据 + Redis::del($this->setKey); + + if (!empty($uids)) { + // 添加新UID集合 + foreach ($uids as $uid) { + Redis::sAdd($this->setKey, $uid); + } + + // 重置索引 + if ($resetIndex) { + Redis::set($this->key, 0); + } + } + + $this->touchExpire(); + return true; + } + + public function next(): ?int + { + $lockAcquired = false; + try { + // 获取锁(改用原生命令) + $lockAcquired = $this->acquireLock(5); + if (!$lockAcquired) { + throw new \RuntimeException("获取锁失败"); + } + + // 获取当前索引 + $currentIndex = (int)Redis::get($this->key); + + // 获取所有UID + $uids = Redis::sMembers($this->setKey); + if (empty($uids)) { + return null; + } + + // 计算位置 + $uid = $uids[$currentIndex % count($uids)]; + $nextIndex = $currentIndex + 1; + + // 更新索引 + Redis::set($this->key, $nextIndex); + $this->touchExpire(); + + return is_numeric($uid) ? (int)$uid : $uid; + } catch (\Throwable $e) { + Log::info("UID分配错误: " . $e->getMessage()); + return null; + } finally { + if ($lockAcquired) { + Redis::del($this->lockKey); + } + } + } + + private function acquireLock(int $timeout = 5): bool + { + $start = time(); + $lockTimeout = $timeout + 1; // 锁过期时间比等待时间长 + + while (time() - $start < $timeout) { + // 使用SETNX实现锁(分两步) + if (Redis::setNx($this->lockKey, 1)) { + // 设置过期时间 + Redis::expire($this->lockKey, $lockTimeout); + return true; + } + + // 检查锁是否已过期但未被删除(处理死锁) + if (Redis::ttl($this->lockKey) < 0) { + Redis::del($this->lockKey); + } + + usleep(100000); // 100ms重试间隔 + } + + return false; + } + + private function touchExpire(): void + { + Redis::expire($this->key, $this->expireSeconds); + Redis::expire($this->setKey, $this->expireSeconds); + } + + public function getPosition(): int + { + return (int)Redis::get($this->key); + } + + public function resetIndex(int $position = 0): bool + { + return (bool)Redis::set($this->key, max(0, $position)); + } + + public function getUids(): array + { + return Redis::sMembers($this->setKey) ?: []; + } + + public function addUids($uids): int + { + $added = 0; + $uids = is_array($uids) ? $uids : [$uids]; + + foreach ($uids as $uid) { + $added += Redis::sAdd($this->setKey, $uid); + } + + $this->touchExpire(); + return $added; + } + + public function removeUids($uids): int + { + $removed = 0; + $uids = is_array($uids) ? $uids : [$uids]; + + foreach ($uids as $uid) { + $removed += Redis::sRem($this->setKey, $uid); + } + + return $removed; + } + + public function count(): int + { + return (int)Redis::sCard($this->setKey); + } + + public function destroy(): void + { + Redis::del([$this->key, $this->setKey, $this->lockKey]); + } +} \ No newline at end of file diff --git a/app/common/utils/TableInspector.php b/app/common/utils/TableInspector.php new file mode 100644 index 0000000..6b00995 --- /dev/null +++ b/app/common/utils/TableInspector.php @@ -0,0 +1,57 @@ +getDatabaseName(); + + $columns = Db::select(" + SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, + COLUMN_DEFAULT, COLUMN_COMMENT, EXTRA + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? + ORDER BY ORDINAL_POSITION + ", [$database, $table]); + + return array_map(function ($col) { + return [ + 'name' => $col->COLUMN_NAME, + 'type' => $col->DATA_TYPE, + 'column_type' => $col->COLUMN_TYPE, + 'nullable' => $col->IS_NULLABLE === 'YES', + 'default' => $col->COLUMN_DEFAULT, + 'comment' => $col->COLUMN_COMMENT, + 'extra' => $col->EXTRA, + ]; + }, $columns); + } + + /** + * 获取字段名列表 + */ + public static function getColumnNames(string $table, ?string $database = null): array + { + return array_column(self::getColumns($table, $database), 'name'); + } + + /** + * 获取字段注释 map:字段名 => 注释 + */ + public static function getColumnComments(string $table, ?string $database = null): array + { + $columns = self::getColumns($table, $database); + $map = []; + foreach ($columns as $col) { + $map[$col['name']] = $col['comment']; + } + return $map; + } +} \ No newline at end of file diff --git a/app/common/utils/Tree.php b/app/common/utils/Tree.php new file mode 100644 index 0000000..b831185 --- /dev/null +++ b/app/common/utils/Tree.php @@ -0,0 +1,505 @@ + $item) { + // if ($item[$pidField] == 0) { + // $tree[] = &$items[$id]; + // } else { + // if (isset($items[$item[$pidField]])) { + // $items[$id]['level'] = $items[$item[$pidField]]['level'] + 1; + + // if (!isset($items[$item[$pidField]]['children'])) { + // $items[$item[$pidField]]['children'] = []; + // } + + // $items[$item[$pidField]]['children'][] = &$items[$id]; + // } + // } + // } + + // return $tree; + // } + + // /** + // * 将树形结构扁平化为列表(带层级前缀) + // * + // * @param array $tree 树形结构数据 + // * @param string $prefix 前缀字符 + // * @param string $field 显示字段名 + // * @param int $level 层级 + // * @return array + // */ + // public static function flattenWithPrefix(array $tree, string $prefix = '--', string $field = 'name', int $level = 0): array + // { + // $result = []; + + // foreach ($tree as $item) { + // $item[$field . '_with_prefix'] = str_repeat($prefix, $item['level']) . $item[$field]; + // $result[] = $item; + + // if (isset($item['children']) && !empty($item['children'])) { + // $children = static::flattenWithPrefix($item['children'], $prefix, $field, $item['level'] + 1); + // $result = array_merge($result, $children); + // } + // } + + // return $result; + // } + + // /** + // * 根据父ID查找所有子节点(包括子孙节点) + // * + // * @param array $data 数据列表 + // * @param int $parentId 父级ID + // * @param string $pidField 父级字段名 + // * @param string $idField ID字段名 + // * @return array + // */ + // public static function findChildren(array $data, int $parentId, string $pidField = 'parent_id', string $idField = 'id'): array + // { + // $children = []; + + // foreach ($data as $item) { + // if ($item[$pidField] == $parentId) { + // $children[] = $item; + // $grandChildren = static::findChildren($data, $item[$idField], $pidField, $idField); + // $children = array_merge($children, $grandChildren); + // } + // } + + // return $children; + // } + + // /** + // * 获取指定节点的所有父级节点 + // * + // * @param array $data 数据列表 + // * @param int $id 当前节点ID + // * @param string $pidField 父级字段名 + // * @param string $idField ID字段名 + // * @return array + // */ + // public static function getParents(array $data, int $id, string $pidField = 'parent_id', string $idField = 'id'): array + // { + // $parents = []; + + // foreach ($data as $item) { + // if ($item[$idField] == $id) { + // if ($item[$pidField] != 0) { + // $parents[] = $item; + // $parentParents = static::getParents($data, $item[$pidField], $pidField, $idField); + // $parents = array_merge($parents, $parentParents); + // } + // break; + // } + // } + + // return $parents; + // } + + // /** + // * 获取指定节点的子孙节点ID列表 + // * + // * @param array $data 数据列表 + // * @param int $parentId 父级ID + // * @param string $pidField 父级字段名 + // * @param string $idField ID字段名 + // * @return array + // */ + // public static function getChildrenIds(array $data, int $parentId, string $pidField = 'parent_id', string $idField = 'id'): array + // { + // $ids = []; + // $children = static::findChildren($data, $parentId, $pidField, $idField); + + // foreach ($children as $child) { + // $ids[] = $child[$idField]; + // } + + // return $ids; + // } + + // /** + // * 检查是否有子节点 + // * + // * @param array $data 数据列表 + // * @param int $parentId 父级ID + // * @param string $pidField 父级字段名 + // * @return bool + // */ + // public static function hasChildren(array $data, int $parentId, string $pidField = 'parent_id'): bool + // { + // foreach ($data as $item) { + // if ($item[$pidField] == $parentId) { + // return true; + // } + // } + + // return false; + // } + + + // /** + // * 构建带有权限列表的资源树(权限不显示在树结构中) + // * + // * @param array $data 资源数据列表 + // * @param int $parentId 父级ID + // * @param string $pidField 父级字段名 + // * @param string $idField ID字段名 + // * @param string $typeField 类型字段名 + // * @param string $permissionType 权限类型标识 + // * @param int $level 当前层级 + // * @return array + // */ + // public static function buildWithAuthList( + // array $data, + // int $parentId = 0, + // string $pidField = 'parent_id', + // string $idField = 'id', + // string $typeField = 'type', + // string $permissionType = 'permission', + // int $level = 0 + // ): array { + // $tree = []; + + // foreach ($data as $item) { + // // 只处理指定父级的非权限节点 + // if ($item[$pidField] == $parentId && $item[$typeField] != $permissionType) { + // $item['level'] = $level; + + // // 递归处理子节点(非权限类型) + // $children = static::buildWithAuthList($data, $item[$idField], $pidField, $idField, $typeField, $permissionType, $level + 1); + + // if (!empty($children)) { + // $item['children'] = $children; + // } + + // // 收集当前节点下的所有权限 + // $item['auth_list'] = static::collectDirectPermissions($data, $item[$idField], $pidField, $typeField, $permissionType); + + // $tree[] = $item; + // } + // } + + // return $tree; + // } + + // /** + // * 收集指定节点下的直接权限项(只收集直接子节点中的权限) + // * + // * @param array $data 数据列表 + // * @param int $parentId 父级ID + // * @param string $pidField 父级字段名 + // * @param string $typeField 类型字段名 + // * @param string $permissionType 权限类型标识 + // * @return array + // */ + // public static function collectDirectPermissions1( + // array $data, + // int $parentId, + // string $pidField = 'parent_id', + // string $typeField = 'type', + // string $permissionType = 'permission' + // ): array { + // $permissions = []; + + // foreach ($data as $item) { + // // 收集直接子节点中的权限类型 + // if ($item[$pidField] == $parentId && $item[$typeField] == $permissionType) { + // $permissions[] = $item; + // } + // } + + // return $permissions; + // } + + // /** + // * 使用引用方式构建带有权限列表的资源树(权限不显示在树结构中) + // * + // * @param array $data 资源数据列表 + // * @param string $pidField 父级字段名 + // * @param string $idField ID字段名 + // * @param string $typeField 类型字段名 + // * @param string $permissionType 权限类型标识 + // * @return array + // */ + // public static function buildWithAuthListAndReference( + // array $data, + // string $pidField = 'parent_id', + // string $idField = 'id', + // string $typeField = 'type', + // string $permissionType = 'permission' + // ): array { + // $items = []; + // $tree = []; + + // // 建立以id为索引的数组(只包含非权限节点) + // foreach ($data as $item) { + // if ($item[$typeField] != $permissionType) { + // $item['level'] = 0; + // $items[$item[$idField]] = $item; + // } + // } + + // // 构建树形结构 + // foreach ($items as $id => $item) { + // if ($item[$pidField] == 0) { + // $tree[] = &$items[$id]; + // } else { + // if (isset($items[$item[$pidField]])) { + // $items[$id]['level'] = $items[$item[$pidField]]['level'] + 1; + + // if (!isset($items[$item[$pidField]]['children'])) { + // $items[$item[$pidField]]['children'] = []; + // } + + // $items[$item[$pidField]]['children'][] = &$items[$id]; + // } + // } + // } + + // // 为每个节点添加权限列表 + // foreach ($items as &$item) { + // $item['auth_list'] = static::collectDirectPermissions($data, $item[$idField], $pidField, $typeField, $permissionType); + // } + + // return $tree; + // } + + + + // 8.13 add + /** + * 构建前端需要的菜单树结构 + * + * @param array $data 服务端菜单数据 + * @param int $parentId 父级ID + * @return array + */ + public static function buildMenuTree(array $data, int $parentId = 0): array + { + $tree = []; + + foreach ($data as $item) { + // 只处理非权限类型的节点 + if ($item['parent_id'] == $parentId && $item['type'] != 'permission') { + // 转换数据结构以匹配前端需要的格式 + $menuItem = [ + 'id' => $item['id'], + 'path' => $item['url'] ?? '', + 'name' => $item['name'], + 'component' => $item['url'] ?? '', + 'meta' => [ + 'title' => $item['name'], + 'icon' => $item['icon'] ?? '', + 'isHide' => !empty($item['is_hide']), + 'isHideTab' => !empty($item['is_hide_tab']), + 'keepAlive' => !empty($item['keep_alive']), + ] + ]; + + // 收集当前节点下的所有权限 + $permissions = static::collectDirectPermissions($data, $item['id']); + if (!empty($permissions)) { + $authList = []; + foreach ($permissions as $permission) { + $authList[] = [ + 'id' => $permission['id'], + 'title' => $permission['name'] ?? '', + 'authMark' => $permission['code'] ?? '', + 'selected' => isset($permission['selected']) && $permission['selected'] ? true : false + ]; + } + $menuItem['meta']['authList'] = $authList; + } + + // 将服务端额外字段放入meta中 + $extraFields = static::getExtraFields($item); + if (!empty($extraFields)) { + $menuItem['meta'] = array_merge($menuItem['meta'], $extraFields); + } + + // 递归处理子菜单 + $children = static::buildMenuTree($data, $item['id']); + if (!empty($children)) { + $menuItem['children'] = $children; + } + + $tree[] = $menuItem; + } + } + + return $tree; + } + + /** + * 使用引用方式构建前端需要的菜单树结构(性能更好) + * + * @param array $data 服务端菜单数据 + * @return array + */ + public static function buildMenuTreeWithReference(array $data): array + { + $items = []; + $tree = []; + + // 过滤出菜单类型的节点并转换数据结构 + $menuData = array_filter($data, function($item) { + return ($item['type'] ?? '') != 'permission'; + }); + + // 建立以id为索引的数组并转换数据结构 + foreach ($menuData as $item) { + $items[$item['id']] = [ + 'id' => $item['id'], + 'path' => $item['url'] ?? '', + 'name' => $item['name'], + 'component' => $item['url'] ?? '', + 'meta' => [ + 'title' => $item['name'], + 'icon' => $item['icon'] ?? '', + 'isHide' => !empty($item['is_hide']), + 'isHideTab' => !empty($item['is_hide_tab']), + 'keepAlive' => !empty($item['keep_alive']), + ] + ]; + + // 将服务端额外字段放入meta中 + $extraFields = static::getExtraFields($item); + if (!empty($extraFields)) { + $items[$item['id']]['meta'] = array_merge($items[$item['id']]['meta'], $extraFields); + } + } + + // 构建树形结构 + foreach ($menuData as $item) { + if ($item['parent_id'] == 0) { + $tree[] = &$items[$item['id']]; + } else { + if (isset($items[$item['parent_id']])) { + if (!isset($items[$item['parent_id']]['children'])) { + $items[$item['parent_id']]['children'] = []; + } + $items[$item['parent_id']]['children'][] = &$items[$item['id']]; + } + } + } + + // 为每个节点添加权限列表 + foreach ($items as &$item) { + $parentId = $item['id']; + $permissions = static::collectDirectPermissions($data, $parentId); + if (!empty($permissions)) { + $authList = []; + foreach ($permissions as $permission) { + $authList[] = [ + 'id' => $permission['id'], + 'title' => $permission['name'] ?? '', + 'authMark' => $permission['code'] ?? '' + ]; + } + $item['meta']['authList'] = $authList; + } + } + + return $tree; + } + + /** + * 收集指定节点下的直接权限项(只收集直接子节点中的权限) + * + * @param array $data 数据列表 + * @param int $parentId 父级ID + * @return array + */ + public static function collectDirectPermissions(array $data, int $parentId): array + { + $permissions = []; + + foreach ($data as $item) { + // 收集直接子节点中的权限类型 + if ($item['parent_id'] == $parentId && ($item['type'] ?? '') == 'permission') { + $permissions[] = $item; + } + } + + return $permissions; + } + + /** + * 获取需要放入meta的额外字段 + * + * @param array $item 菜单项 + * @return array + */ + public static function getExtraFields(array $item): array + { + $extraFields = []; + $standardFields = [ + 'id', 'name', 'url', 'icon', 'is_hide', 'is_hide_tab', 'keep_alive', 'level' + ]; + + foreach ($item as $key => $value) { + // 如果不是标准字段且不是auth_list,则放入extraFields + if (!in_array($key, $standardFields) && $key !== 'auth_list') { + // 转换下划线命名到驼峰命名 + $camelKey = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $key)))); + $extraFields[$camelKey] = $value; + } + } + + return $extraFields; + } +} \ No newline at end of file diff --git a/app/common/utils/UidRotator.php b/app/common/utils/UidRotator.php new file mode 100644 index 0000000..7e0746c --- /dev/null +++ b/app/common/utils/UidRotator.php @@ -0,0 +1,178 @@ +registerAddress; + } + + /** + * 监听消息接收 + */ + public function message($client_id, $message) + { + $this->clientId = $client_id; + + $payload = json_decode($message, true); + $event = $payload['event']; + $this->eventData = $payload['data'] ?? []; + $traceId = $payload['trace_id'] ?? null; + + $method = $this->eventToMethod($event); + + if (method_exists($this, $method)) { + try { + $this->$method($traceId); + } catch (\Throwable $th) { + Gateway::sendToClient($client_id, WsResponse::error( + $event, + 500, + $th->getMessage(), + $traceId + )); + } + } else { + Gateway::sendToClient($client_id, WsResponse::error( + $event, + 404, + "Event handler not found: $method", + $traceId + )); + } + } + + protected function eventToMethod(string $event): string + { + $parts = explode('.', $event); + $method = array_shift($parts); + foreach ($parts as $part) { + $method .= ucfirst($part); + } + return $method; + } + + + // ================= 系统事件 ================= + /** + * 认证 + */ + public function auth($client_id, $payload) + { + try { + // 检查来源 + if (!isset($payload['server']['HTTP_ORIGIN'])) { + flg('[x] [TOKEN认证] 未定义HTTP_ORIGIN', false); + Gateway::sendToClient($client_id, WsResponse::error( + 'system.auth', + 1001, + '[x] [TOKEN认证] 未定义HTTP_ORIGIN' + )); + Gateway::closeClient($client_id); + return; + } + + // 连接是否满足条件 + if (getenv('ENVIRONMENT') === 'master' && $payload['server']['HTTP_ORIGIN'] === getenv('MAIN_DOMAIN')) { + flg('[x] [TOKEN认证] HTTP_ORIGIN不满足条件', false); + Gateway::sendToClient($client_id, WsResponse::error( + 'system.auth', + 1001, + '[x] [TOKEN认证] HTTP_ORIGIN不满足条件' + )); + Gateway::closeClient($client_id); + return; + } + + // 签名检查 + if (!isset($payload['get']['token'])) { + flg('[x] [TOKEN认证] 未携带TOKEN参数', false); + Gateway::sendToClient($client_id, WsResponse::error( + 'system.auth', + 1001, + '[x] [TOKEN认证] 未携带TOKEN参数' + )); + Gateway::closeClient($client_id); + return; + } + + // 解析TOKEN + $user = (new Jwt())->verifyToken($payload['get']['token']); + + // 绑定用户ID + Gateway::bindUid($client_id, $user->uid); + + // 绑定SESSION + $_SESSION['uid'] = $user->uid; + // $_SESSION['name'] = $user->uname; + + // 响应 + Gateway::sendToClient($client_id, WsResponse::success( + 'system.auth', + ['uid' => $user->uid] + )); + } catch (\Throwable $th) { + flg('[x] [TOKEN认证] TOKEN认证失败:'.$th->getMessage(), false); + Gateway::sendToClient($client_id, WsResponse::error( + 'system.auth', + 1001, + '[x] [TOKEN认证] TOKEN认证失败:'.$th->getMessage() + )); + Gateway::closeClient($client_id); + return; + } + } + + public function logout(string $client_id) + { + // var_dump('====== ====== ====== start ====== ====== ======'); + // var_dump('关闭客户端ID:'.$client_id); + // if ($_SESSION !== null) { + // var_dump($_SESSION); + // } + // var_dump('====== ====== ====== end ====== ====== ======'); + } + + /** + * 系统心跳 + * + * @param string|null $traceId + */ + public function systemPing(?string $traceId) + { + Gateway::sendToClient($this->clientId, WsResponse::success( + 'system.pong', + ['time' => time()], + $traceId + )); + } + + + public function systemConnect(?string $traceId) + { + // 响应 + Gateway::sendToClient($this->clientId, WsResponse::success( + 'system.connect.receive', + ['time' => time()], + $traceId + )); + + // 获取审批待办数量 + $num = (new \app\common\utils\approval\service\ApprovalFlowService())->getInitTodoCount($_SESSION['uid']); + $this->todoCounts($_SESSION['uid'], $num); + + // 绑定检查 + $initData = $this->initCheck($_SESSION['uid']); + Gateway::sendToClient($this->clientId, WsResponse::success( + 'system.init.check', + $initData, + $traceId + )); + } + + // ================= 消息通知事件 ================= + /** + * 管理端主动推送通知 + */ + public function notifyPush(?string $traceId) + { + $title = $this->eventData['title'] ?? null; + $content = $this->eventData['content'] ?? null; + $toUids = $this->eventData['to_uids'] ?? []; + + if (!$title || !$content) { + Gateway::sendToClient($this->clientId, WsResponse::error( + 'notify.push.result', + 400, + '标题和内容不能为空', + $traceId + )); + return; + } + + // 写消息 + $msgService = new MessageNotificationsService(); + $msgId = $msgService->createMessage([ + 'sender_id' => $this->eventData['sender_id'] ?? 0, + 'sender_type' => $this->eventData['sender_type'] ?? 'system', + 'title' => $title, + 'content' => $content, + 'scene' => $this->eventData['scene'] ?? 0, + 'recipient' => $this->eventData['to_uids'] ? implode(',', $this->eventData['to_uids']) : '', + 'push_method' => isset($this->eventData['push_method']) ? $this->eventData['push_method'] : 0 + ]); + + // 写用户状态 + $uids = !empty($toUids) ? $toUids : $msgService->getAllUserIds(); + $msgService->addUserStatus($msgId, $uids); + + $payload = WsResponse::success('notify.receive', [ + 'msg_id' => $msgId, + 'title' => $title, + 'content' => $content, + 'created_at' => time() + ], $traceId); + + if (!empty($toUids)) { + // 推送给指定用户 + foreach ($toUids as $uid) { + Gateway::sendToUid($uid, $payload); + } + } else { + // 推送给全体在线用户 + Gateway::sendToAll($payload); + } + + // 回复发送者确认 + Gateway::sendToClient($this->clientId, WsResponse::success( + 'notify.push.result', + ['msg_id' => $msgId], + $traceId, + '消息发送成功' + )); + } + + /** + * 客户端标记通知已读 + */ + public function notifyRead(?string $traceId) + { + $msgIds = $this->eventData['msg_ids']; + if (!is_array($msgIds) && $msgIds !== 'all') { + Gateway::sendToClient($this->clientId, WsResponse::error( + 'notify.read.result', + 400, + '消息ID不能为空', + $traceId + )); + return; + } + + // 更新消息为已读 + $msgService = new MessageNotificationsService(); + $msgService->markAsRead($msgIds, Gateway::getUidByClientId($this->clientId)); + + Gateway::sendToClient($this->clientId, WsResponse::success( + 'notify.read.result', + ['msg_ids' => $msgIds], + $traceId + )); + } + + + /* + approval.todo.counts 审批待办数量 + + */ + + // ================= 主动推送 ================= + + public function pushWebsocket(string $event, int|string $uid, array $data) + { + return match (gettype($uid)) { + 'integer' => Gateway::sendToUid($uid, WsResponse::success($event, $data)), + 'string' => Gateway::sendToClient($uid, WsResponse::success($event, $data)), + default => throw new \InvalidArgumentException('UID must be integer or string'), + }; + } + + + + + + + + + + + + // ================= 业务处理 ================= + + /** + * 初始化检查 + */ + public function initCheck(int $userId) + { + $data = [ + // 'is_report_to' => false, + // 'is_office_clerk' => false, + 'is_sign' => false + ]; + + $user = \app\common\model\User::find($userId); + // if ($user->report_to > 0) { + // $data['is_report_to'] = true; + // } + + if ($user->sign_img !== null && $user->sign_img !== '') { + $data['is_sign'] = true; + } + + // $ug = \app\common\model\UserGroupMembers::with(['group' => function($query) { + // $query->where('scenario_code', 'stock_orders'); + // }])->where('user_id', $userId)->first(); + // if ($ug !== null) { + // $data['is_office_clerk'] = true; + // } + + if ($data['is_sign']) { + Redis::hSet('user:check:sign'.$userId, 'status', 1); + } + + return $data; + } + + // 审批待办数量 + public function todoCounts(int $userId, int $num = 0) + { + $ly = [ + 1 => 186, + 3 => 224, + ]; + + if ($num === 0) { + $num = (new \app\common\utils\approval\service\ApprovalFlowService())->getInitTodoCount($userId); + } + + $systemIds = \app\common\model\UserSystems::where('user_id', $userId)->pluck('system_id')->toArray(); + if (count($systemIds) > 0) { + foreach ($systemIds as $systemId) { + if (isset($ly[$systemId])) { + $chain = \app\common\service\MenuBadgeService::buildBadgeChain(menuId: $ly[$systemId], leafCount: $num); + $this->pushWebsocket('approval.todo.counts', $userId, $chain); + } + } + } + } +} \ No newline at end of file diff --git a/app/common/utils/WsResponse.php b/app/common/utils/WsResponse.php new file mode 100644 index 0000000..6cf0b28 --- /dev/null +++ b/app/common/utils/WsResponse.php @@ -0,0 +1,47 @@ + $event, + 'code' => $code, + 'message' => $message, + 'data' => $data, + 'trace_id' => $traceId, // 如果没有传就是 null + ], JSON_UNESCAPED_UNICODE); + } + + /** + * 错误返回 + * + * @param string $event 事件名称 + * @param int $code 错误码 + * @param string $message 错误信息 + * @param string|null $traceId 请求追踪ID,可选,默认 null + * @param array $data 附加数据(可选) + * @return string JSON + */ + public static function error(string $event, int $code, string $message, ?string $traceId = null, array $data = []): string + { + return json_encode([ + 'event' => $event, + 'code' => $code, + 'message' => $message, + 'data' => $data, + 'trace_id' => $traceId, // 如果没有传就是 null + ], JSON_UNESCAPED_UNICODE); + } +} \ No newline at end of file diff --git a/app/common/utils/crawl/service/UsedCarCrawl.php b/app/common/utils/crawl/service/UsedCarCrawl.php new file mode 100644 index 0000000..7e99090 --- /dev/null +++ b/app/common/utils/crawl/service/UsedCarCrawl.php @@ -0,0 +1,857 @@ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept-Language' => 'zh-CN,zh;q=0.9', + 'Referer' => 'https://www.dongchedi.com/', + ]; + + // Redis队列键名 + private const REDIS_QUEUE_KEY = 'usedcar:urls'; + + // 能源类型常量 + public const ENERGY_TYPE_RENEWABLE = 'RE'; + public const ENERGY_TYPE_CONVENTIONAL = 'CE'; + public const ENERGY_TYPE_UNKNOWN = 'UNKNOWN'; + + /** + * 插入队列url + * + * @param string $url URL地址 + * @param string $scene 场景:usedcar(二手信息) config(配置信息) + * @param int $id 记录ID + * @return int + */ + public function pushUrl(string $url = '', string $scene = 'usedcar', int $id = 0): int + { + $data = [ + 'scene' => $scene, + 'url' => $url, + 'id' => $id, + ]; + + return Redis::lpush( + self::REDIS_QUEUE_KEY, + json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + } + + /** + * 获取队列url + * + * @return array|null + * @throws BusinessException + */ + public function popUrl(): ?array + { + $data = Redis::rPop(self::REDIS_QUEUE_KEY); + if (!$data) { + return null; + } + + $task = json_decode($data, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($task)) { + throw new BusinessException('任务数据解析失败:' . json_last_error_msg()); + } + + // 基本字段校验 + if (empty($task['url']) || empty($task['scene'])) { + throw new BusinessException('队列任务数据缺少必要字段:url 或 scene'); + } + + return $task; + } + + /** + * 执行抓取任务 + * + * @return bool + * @throws BusinessException + */ + public function execute(): bool + { + try { + $task = $this->popUrl(); + + if (!$task) { + // 队列为空,任务结束 + return false; + } + + $url = (string)$task['url']; + $scene = (string)$task['scene']; + $id = (int)($task['id'] ?? 0); + + $html = $this->fetchHtml($url); + if ($html === '') { + throw new BusinessException("页面内容为空: {$url}"); + } + + switch ($scene) { + case 'usedcar': + $info = $this->parseUsedCarDetail($html, $url); + $insertId = $this->saveUsedCarBasicInfo($info); + + // 下载图片 + 上传 + 入库(图片失败不影响主记录) + $imagesArray = json_decode($info['images'] ?? '[]', true); + if (json_last_error() !== JSON_ERROR_NONE) { + $imagesArray = []; + } + $saveDir = public_path('uploads/usedcar/' . $insertId); + $this->uploadAndRemove($imagesArray, $saveDir, $info['used_car_id'], $insertId); + + // 继续派发配置页任务 + if (!empty($info['car_id_url'])) { + $this->pushUrl($info['car_id_url'], 'config', $insertId); + } + break; + + case 'config': + if ($id <= 0) { + throw new BusinessException("配置任务缺少主记录ID"); + } + + $data = $this->parseConfigDetail($html); + $this->saveUsedCarConfigInfo($data, $id); + break; + + default: + throw new BusinessException("未知的任务场景: " . $scene); + } + + return true; + } catch (GuzzleException $e) { + throw new BusinessException("网络请求失败: " . $e->getMessage()); + } catch (BusinessException $e) { + // 业务异常原样抛出,方便上层知道是逻辑问题 + throw $e; + } catch (Throwable $th) { + // 兜底异常,避免 worker 直接挂掉 + throw new BusinessException( + "执行任务出错: " . $th->getMessage() + . ' | 行号: ' . $th->getLine() + . ' | 文件: ' . $th->getFile() + ); + } + } + + /** + * 获取网页HTML内容 + * + * @param string $url + * @return string + * @throws GuzzleException + */ + public function fetchHtml(string $url): string + { + $client = new Client(); + $response = $client->request('GET', $url, [ + 'headers' => self::DEFAULT_HEADERS, + 'timeout' => 10, + ]); + + return (string)$response->getBody(); + } + + /** + * 解析二手车详情页信息 + * + * @param string $html + * @param string $url + * @return array + * @throws BusinessException + */ + public function parseUsedCarDetail(string $html = '', string $url = ''): array + { + try { + $ql = QueryList::html($html); + } catch (Throwable $e) { + throw new BusinessException('解析 HTML 失败(详情页): ' . $e->getMessage()); + } + + $title = trim($ql->find('h1')->text()); + $price = trim($ql->find('.head-info_price-wrap__Y4bxi > p')->text()); + $pageText = $ql->find('body')->text(); + $jsonText = $ql->find('#__NEXT_DATA__')->text(); + + if ($jsonText === '') { + throw new BusinessException('详情页未找到 __NEXT_DATA__ JSON'); + } + + $jsonArray = $this->decodeJson($jsonText, '详情页 __NEXT_DATA__'); + + // 车辆相册 + $images = $jsonArray['props']['pageProps']['skuDetail']['head_images'] ?? []; + + // 车辆ID / 年份 + $carInfo = $jsonArray['props']['pageProps']['skuDetail']['car_info'] ?? []; + $carId = $carInfo['car_id'] ?? 0; + $year = (int)($carInfo['year'] ?? 0); + + // 车辆ID URL + $carIdUrl = $carId ? ('https://www.dongchedi.com/auto/params-carIds-' . $carId) : ''; + + // 能源类型(两个字段择一) + $overview = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview'] ?? []; + $fuelForm = $overview['power']['fuel_form'] ?? ''; + $newFuelForm = $overview['new_energy_power']['fuel_form'] ?? ''; + $resultFuelForm = $fuelForm === '' ? $newFuelForm : $fuelForm; + + // 使用里程 + $importantText = $jsonArray['props']['pageProps']['skuDetail']['important_text'] ?? ''; + $importantTextArray = $importantText ? explode(' | ', $importantText) : []; + $usageMileage = ''; + if (isset($importantTextArray[1])) { + $usageMileage = str_replace('万公里', '', $importantTextArray[1]); + } + + // 车辆名称 品牌、车系、车型 + $brandName = $carInfo['brand_name'] ?? ''; + $seriesName = $carInfo['series_name'] ?? ''; + $carName = $carInfo['car_name'] ?? ''; + + // 商家信息 + $shopInfo = $jsonArray['props']['pageProps']['skuDetail']['shop_info'] ?? []; + $formatShopInfo = [ + 'shop_id' => $shopInfo['shop_id'] ?? 0, + 'shop_name' => $shopInfo['shop_short_name'] ?? '', + 'shop_address' => $shopInfo['shop_address'] ?? '', + ]; + + // 简介参数 + $otherParams = $jsonArray['props']['pageProps']['skuDetail']['other_params'] ?? []; + $displacement = $this->getParamValue($otherParams, '排量'); + $gearbox = $this->getParamValue($otherParams, '变速箱'); + + $formatCarInfo = [ + 'displacement' => $displacement, + 'gearbox' => $gearbox, + ]; + + // 提取新车指导价 / 比新车省金额 + $newPrice = $this->extractPatternValue($pageText, '/新车指导价[::]\s*([^\r\n万]*万)/u'); + $priceDifference = $this->extractPatternValue($pageText, '/比新车省[::]\s*([^\r\n万]*万)/u'); + + // 提取车况介绍 + $conditionDesc = ''; + if ($pageText && preg_match('/车况介绍\s*(.*?)\s*推荐理由/su', $pageText, $m)) { + $conditionDesc = trim($m[1]); + } + + return [ + 'used_car_id' => $this->extractIdFromUrl($url), + 'car_id' => $carId, + 'url' => $url, + 'title' => $title, + 'price' => str_replace('万', '', $this->decodePrice($price)), + 'new_price' => str_replace('万', '', $newPrice), + 'price_difference' => str_replace('万', '', $priceDifference), + 'condition_desc' => $conditionDesc, + 'images' => json_encode($images, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'car_id_url' => $carIdUrl, + 'used_detail' => $jsonText, + 'format_shop_info' => json_encode($formatShopInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'fuel_form' => $resultFuelForm, + 'classify_energy' => $this->detectEnergyType($resultFuelForm), + 'usage_mileage' => $usageMileage, + 'brand_name' => $brandName, + 'series_name' => $seriesName, + 'car_name' => $carName, + 'year' => $year, + 'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]; + } + + /** + * 解析配置详情页信息 + * + * @param string $html + * @return array + * @throws BusinessException + */ + public function parseConfigDetail(string $html = ''): array + { + try { + $ql = QueryList::html($html); + } catch (Throwable $e) { + throw new BusinessException('解析 HTML 失败(配置页): ' . $e->getMessage()); + } + + $jsonText = $ql->find('#__NEXT_DATA__')->text(); + if ($jsonText === '') { + throw new BusinessException('配置页未找到 __NEXT_DATA__ JSON'); + } + + $raw = $this->decodeJson($jsonText, '配置页 __NEXT_DATA__'); + + $properties = $raw['props']['pageProps']['rawData']['properties'] ?? []; + $carInfo = $raw['props']['pageProps']['rawData']['car_info'][0] ?? []; + + $valuesMap = $this->buildValuesMap($carInfo); + $groups = $this->buildCarSpecGroups($properties, $valuesMap); + + return [ + 'format_car_info' => [ + 'market_time' => $this->getFieldValue($groups, 'market_time'), // 上市时间 + 'driver_form' => $this->getFieldValue($groups, 'driver_form'), // 驱动形式 + 'seat_count' => $this->getFieldValue($groups, 'seat_count'), // 座位数 + 'engine_description' => $this->getFieldValue($groups, 'engine_description'), // 发动机 + 'engine_max_power_horsepower' => $this->getFieldValue($groups, 'engine_max_power_horsepower'), // 发动机最大功率 + 'electric_description' => $this->getFieldValue($groups, 'electric_description'), // 电动机 + 'battery_capacity' => $this->getFieldValue($groups, 'battery_capacity'), // 电池容量 + 'battery_type' => $this->getFieldValue($groups, 'battery_type'), // 电池类型 + 'recharge_mileage' => $this->getFieldValue($groups, 'recharge_mileage'), // 续航里程 + ], + 'groups' => $groups, + ]; + } + + /** + * 从车辆配置中获取能源类型 + * + * @param array $data JSON数据 + * @return string|null 能源类型 + */ + public function getFuelFormByConfig(array $data): ?string + { + if (empty($data[0]['items']) || !is_array($data[0]['items'])) { + return null; + } + + foreach ($data[0]['items'] as $item) { + if (isset($item['key']) && $item['key'] === 'fuel_form') { + return $item['value'] ?? null; + } + } + + return null; + } + + /** + * 从数据中根据字段key获取值 + * + * @param mixed $data 数据 + * @param string $field 字段名 + * @return string|null + */ + public function getFieldValue($data, string $field): ?string + { + if (is_string($data)) { + $data = json_decode($data, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + } + + if (!is_array($data)) { + return null; + } + + foreach ($data as $group) { + if (isset($group['items']) && is_array($group['items'])) { + foreach ($group['items'] as $item) { + if (($item['key'] ?? null) === $field) { + return $item['value'] ?? null; + } + + if (!empty($item['children']) && is_array($item['children'])) { + foreach ($item['children'] as $child) { + if (($child['key'] ?? null) === $field) { + return $child['value'] ?? null; + } + } + } + } + } + } + + return null; + } + + /** + * 从参数列表中获取指定名称的值 + */ + public function getParamValue(array $params, string $name): ?string + { + foreach ($params as $item) { + if (($item['name'] ?? null) === $name) { + return $item['value'] ?? null; + } + } + return null; + } + + /** + * 判断车辆能源类型 + */ + public function detectEnergyType(string $text): string + { + if (function_exists('mb_internal_encoding')) { + mb_internal_encoding('UTF-8'); + } + + // 明确定义的新能源关键字 + $newEnergyKeywords = [ + '纯电', '纯电动', '电动车', 'BEV', + '插电混动', '插电式混合', '插电式', '插电', 'PHEV', + '增程式', '增程', 'REEV', + '氢能', '氢燃料', '燃料电池', 'FCEV', + '太阳能', + ]; + + // 传统能源关键字 + $traditionalKeywords = [ + '汽油', + '柴油', + '天然气', 'LNG', 'CNG', + ]; + + // 普通混动关键字(归为传统能源) + $hybridKeywords = [ + '油电混合', '油电混动', '油电', + '混动', + '双擎', + 'HEV', + ]; + + $contains = function (string $haystack, string $needle): bool { + if ($needle === '') { + return false; + } + if (function_exists('mb_stripos')) { + return mb_stripos($haystack, $needle) !== false; + } + return stripos($haystack, $needle) !== false; + }; + + foreach ($newEnergyKeywords as $kw) { + if ($contains($text, $kw)) { + return self::ENERGY_TYPE_RENEWABLE; + } + } + + foreach ($hybridKeywords as $kw) { + if ($contains($text, $kw)) { + return self::ENERGY_TYPE_CONVENTIONAL; + } + } + + foreach ($traditionalKeywords as $kw) { + if ($contains($text, $kw)) { + return self::ENERGY_TYPE_CONVENTIONAL; + } + } + + return self::ENERGY_TYPE_UNKNOWN; + } + + /** + * 保存二手车基本信息到数据库 + * + * @param array $data 数据 + * @return int 插入记录ID + * @throws BusinessException + */ + public function saveUsedCarBasicInfo(array $data): int + { + try { + $time = time(); + $data['created_at'] = $data['created_at'] ?? $time; + $data['updated_at'] = $time; + + return (int)Db::table('usedcar_crawl_results')->insertGetId($data); + } catch (Throwable $e) { + throw new BusinessException('保存二手车基础信息失败:' . $e->getMessage()); + } + } + + /** + * 更新配置信息到数据库 + * + * @param array $data 配置数据 + * @param int $id 记录ID + * @return bool|int + * @throws BusinessException + */ + public function saveUsedCarConfigInfo(array $data, int $id) + { + try { + $record = Db::table('usedcar_crawl_results')->find($id); + + if (!$record) { + // 找不到记录就算了,不抛致命错误,视业务情况也可以抛异常 + return false; + } + + $groups = $data['groups'] ?? []; + $oldFormat = []; + + if (!empty($record->format_car_info)) { + $decoded = json_decode($record->format_car_info, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $oldFormat = $decoded; + } + } + + $formatCarInfo = array_merge( + $oldFormat, + $data['format_car_info'] ?? [] + ); + + return Db::table('usedcar_crawl_results') + ->where('id', $id) + ->update([ + 'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'config_detail' => json_encode($groups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'updated_at' => time(), + ]); + } catch (Throwable $e) { + throw new BusinessException('保存车辆配置信息失败:' . $e->getMessage()); + } + } + + /** + * 构建值映射表 + */ + public function buildValuesMap(array $carInfo): array + { + $valuesMap = []; + + // 顶层字段 + foreach ($carInfo as $k => $v) { + if ($k === 'info' || is_array($v)) { + continue; + } + $valuesMap[$k] = ['value' => $v]; + } + + // info中的配置项 + if (!empty($carInfo['info']) && is_array($carInfo['info'])) { + foreach ($carInfo['info'] as $k => $v) { + if (is_array($v) && array_key_exists('value', $v)) { + $valuesMap[$k] = $v; + } + } + } + + return $valuesMap; + } + + /** + * 构建车辆规格分组 + */ + public function buildCarSpecGroups(array $properties, array $valuesMap): array + { + $groups = []; + $currentKey = null; + + foreach ($properties as $item) { + $type = (int)($item['type'] ?? 1); + $key = $item['key'] ?? ''; + $text = $item['text'] ?? ''; + $subList = $item['sub_list'] ?? null; + + // 分类字段:type = 0 / 2 + if (in_array($type, [0, 2], true)) { + $currentKey = $key ?: ($text ?: ('group_' . count($groups))); + + if (!isset($groups[$currentKey])) { + $groups[$currentKey] = [ + 'group_key' => $currentKey, + 'group_text' => $text, + 'group_type' => $type, + 'items' => [], + ]; + } + + continue; + } + + // 普通字段:挂到最近的分类下 + if ($currentKey === null) { + $currentKey = '_default'; + $groups[$currentKey] = [ + 'group_key' => '_default', + 'group_text' => '其他', + 'group_type' => -1, + 'items' => [], + ]; + } + + $value = ''; + if ($key && isset($valuesMap[$key])) { + $value = trim((string)($valuesMap[$key]['value'] ?? '')); + } + + $node = [ + 'key' => $key, + 'text' => $text, + 'value' => $value, + ]; + + if (is_array($subList) && !empty($subList)) { + $children = []; + foreach ($subList as $sub) { + $subKey = $sub['key'] ?? ''; + $subText = $sub['text'] ?? ''; + $subValue = ''; + + if ($subKey && isset($valuesMap[$subKey])) { + $subValue = trim((string)($valuesMap[$subKey]['value'] ?? '')); + } + + $children[] = [ + 'key' => $subKey, + 'text' => $subText, + 'value' => $subValue, + ]; + } + + $node['children'] = $children; + } + + $groups[$currentKey]['items'][] = $node; + } + + return array_values($groups); + } + + /** + * 解码懂车帝价格 + */ + protected function decodePrice(string $str): string + { + $map = [ + "\u{E53D}" => '1', + "\u{E3F0}" => '2', + "\u{E422}" => '3', + "\u{E42C}" => '4', + "\u{E49C}" => '5', + "\u{E42B}" => '6', + "\u{E4FE}" => '7', + "\u{E548}" => '8', + "\u{E4C8}" => '9', + "\u{E453}" => '0', + "\u{E45F}" => '万', + ]; + + return strtr($str, $map); + } + + /** + * 从URL中提取ID + */ + protected function extractIdFromUrl(string $url) + { + if (preg_match('/\/(\d+)$/', $url, $matches)) { + return (int)$matches[1]; + } + return false; + } + + /** + * 提取符合正则表达式的值(只保留数字和小数点) + */ + protected function extractPatternValue(string $text, string $pattern): string + { + $value = ''; + if ($text && preg_match($pattern, $text, $m)) { + $value = trim(preg_replace('/[^0-9.]/', '', $m[1])); + } + return $value; + } + + /** + * 下载单张图片 + */ + public function downloadImage(string $url, string $saveDir, ?string $filename = null, array $headers = []): ?string + { + if (!is_dir($saveDir)) { + if (!@mkdir($saveDir, 0777, true) && !is_dir($saveDir)) { + // echo "[Image] 创建目录失败:{$saveDir}\n"; + return null; + } + } + + if ($filename === null) { + $path = parse_url($url, PHP_URL_PATH); + $ext = pathinfo($path ?? '', PATHINFO_EXTENSION); + + if ($ext === '') { + $ext = 'jpg'; + } + + $filename = md5($url) . '.' . $ext; + } + + $savePath = rtrim($saveDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename; + + $defaultHeaders = [ + 'User-Agent: ' . self::DEFAULT_HEADERS['User-Agent'], + 'Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + 'Referer: ' . self::DEFAULT_HEADERS['Referer'], + ]; + + foreach ($headers as $h) { + $defaultHeaders[] = $h; + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_HTTPHEADER => $defaultHeaders, + ]); + + $data = curl_exec($ch); + $errNo = curl_errno($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($errNo !== 0 || $code !== 200 || $data === false || $data === '') { + return null; + } + + $bytes = @file_put_contents($savePath, $data); + if ($bytes === false) { + return null; + } + + return $savePath; + } + + /** + * 批量下载相册 + * + * @param array $urls 图片URL数组 + * @param string $saveDir 保存目录 + * @return array [原始URL => 保存路径或null] + */ + public function downloadImageAlbum(array $urls, string $saveDir): array + { + $result = []; + + foreach ($urls as $url) { + $url = trim((string)$url); + if ($url === '') { + continue; + } + + if (strpos($url, '//') === 0) { + $url = 'https:' . $url; + } + + try { + $path = $this->downloadImage($url, $saveDir); + } catch (Throwable $e) { + $path = null; + } + + $result[$url] = $path; + } + + return $result; + } + + /** + * 下载 -> 上传 -> 删除本地 + * + * @param array $images 原始相册URL列表 + * @param string $path 本地保存目录 + * @param int $usedCarId 懂车帝 used_car_id + * @param int $id 本地 crawl 记录id + * @return bool + */ + public function uploadAndRemove(array $images, string $path, $usedCarId, $id): bool + { + if (empty($images)) { + return false; + } + + // 下载图片 + $filePatch = $this->downloadImageAlbum($images, $path); + if (empty($filePatch)) { + return false; + } + + $uploadService = new UploadService(); + + foreach ($filePatch as $url => $filePath) { + if ($filePath === null) { + continue; + } + + try { + // 上传云端 + $param = [ + 'path' => $filePath, + 'scene' => 'usedcar_crawl', + 'category' => 'usedcar', + ]; + + $result = $uploadService->uploadLocalFile($param); + if (empty($result['file_name'])) { + // 上传失败则跳过,但不抛异常影响主流程 + continue; + } + + // 保存图片信息 + UsedcarCrawlResultsImages::create([ + 'uc_id' => $id, + 'used_car_id' => $usedCarId, + 'original_name'=> $result['original_name'] ?? '', + 'file_path' => $result['file_name'], + ]); + } catch (Throwable $e) { + // 单张图失败不影响其他 + // 可以按需写日志 + } finally { + // 删除本地文件 + if (is_file($filePath)) { + @unlink($filePath); + } + } + } + + // 删除目录 + rmdir($path); + + return true; + } + + /** + * 安全解 JSON + * + * @param string $json + * @param string $context + * @return array + * @throws BusinessException + */ + protected function decodeJson(string $json, string $context = 'JSON'): array + { + $data = json_decode($json, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { + throw new BusinessException($context . ' 解析失败:' . json_last_error_msg()); + } + return $data; + } +} \ No newline at end of file diff --git a/app/common/utils/crawl/service/UsedCarCrawl_bak.php b/app/common/utils/crawl/service/UsedCarCrawl_bak.php new file mode 100644 index 0000000..11ea8c8 --- /dev/null +++ b/app/common/utils/crawl/service/UsedCarCrawl_bak.php @@ -0,0 +1,681 @@ + $scene, + 'url' => $url, + 'id' => $id + ], JSON_UNESCAPED_UNICODE)); + } + + /** + * 获取队列url + * + * $scene 二手信息 usedcar 配置信息 config + */ + public function rUrl() + { + return json_decode(Redis::rPop('usedcar:urls'), true); + } + + /** + * 执行抓取 + */ + public function doData() + { + try { + $raw = $this->rUrl(); + + if (!$raw) { + return false; + } + + $html = $this->getHtml($raw['url']); + + switch ($raw['scene']) { + case 'usedcar': + $info = $this->getUsedDetail($html, $raw['url']); + $id = $this->saveUsedcarBasicToDb($info); + $this->iUrl($info['car_id_url'], 'config', $id); + break; + + case 'config': + $data = $this->getConfigDetail($html); + $this->saveUsedcarConfigToDb($data, $raw['id']); + break; + } + + return true; + } catch (\Throwable $th) { + throw new BusinessException($th->getMessage().'|'.$th->getLine().'|'.$th->getFile()); + } + } + + /** + * 获取html + */ + public function getHtml($url) + { + $client = new Client(); + $res = $client->request('GET', $url, [ + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + . 'AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Chrome/120.0.0.0 Safari/537.36', + 'Accept-Language' => 'zh-CN,zh;q=0.9', + 'Referer' => 'https://www.dongchedi.com/' + ] + ]); + return (string)$res->getBody(); + } + + /** + * 获取二手信息 + */ + public function getUsedDetail(string $html = '', string $url = '') + { + $ql = QueryList::html($html); + + $title = $ql->find('h1')->text(); + $price = $ql->find('.head-info_price-wrap__Y4bxi > p')->text(); + $pageText = $ql->find('body')->text(); + $jsonText = $ql->find('#__NEXT_DATA__')->text(); + + // 新车指导价 + $newPrice = ''; + if ($pageText) { + if (preg_match('/新车指导价[::]\s*([^\r\n万]*万)/u', $pageText, $m)) { + $newPrice = trim(preg_replace('/[^0-9.]/', '', $m[1])); + } + } + + // 比新车省 + $priceDifference = ''; + if ($pageText) { + if (preg_match('/比新车省[::]\s*([^\r\n万]*万)/u', $pageText, $m)) { + $priceDifference = trim(preg_replace('/[^0-9.]/', '', $m[1])); + } + } + + // 车况介绍 + $conditionDesc = ''; + if ($pageText) { + if (preg_match('/车况介绍\s*(.*?)\s*推荐理由/su', $pageText, $m)) { + $conditionDesc = trim($m[1]); + } + } + + $jsonArray = json_decode($jsonText, true); + + // 车辆相册 + $images = $jsonArray['props']['pageProps']['skuDetail']['head_images'] ?? []; + + // 车辆ID + $carId = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_id'] ?? 0; + + // 车辆年份 + $year = $jsonArray['props']['pageProps']['skuDetail']['car_info']['year'] ?? 0; + + // 车辆ID URL + $carIdUrl = $carId ? ('https://www.dongchedi.com/auto/params-carIds-' . $carId) : ''; + + // 能源类型 + $fuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['power']['fuel_form'] ?? ''; + $newFuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['new_energy_power']['fuel_form'] ?? ''; + $resultFuelForm = $fuelForm === '' ? $newFuelForm : $fuelForm; + + // 使用里程 + $importantText = $jsonArray['props']['pageProps']['skuDetail']['important_text'] ?? ''; + $importantTextArray = explode(' | ', $importantText); + $usageMileage = str_replace('万公里', '', isset($importantTextArray[1]) ? $importantTextArray[1] : ''); + + // 车辆名称 品牌、车系、车型 + $brandName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['brand_name'] ?? ''; + $seriesName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['series_name'] ?? ''; + $carName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_name'] ?? ''; + + // 商家信息 + $shopInfo = $jsonArray['props']['pageProps']['skuDetail']['shop_info'] ?? []; + $formatShopInfo = [ + 'shop_id' => $shopInfo['shop_id'] ?? 0, + 'shop_name' => $shopInfo['shop_short_name'] ?? '', + 'shop_address' => $shopInfo['shop_address'] ?? '' + ]; + + // 简介参数 + $otherParams = $jsonArray['props']['pageProps']['skuDetail']['other_params'] ?? []; + // 排量 + $displacement = $this->getOtherParamValue($otherParams, '排量'); + // 变速箱 + $gearbox = $this->getOtherParamValue($otherParams, '变速箱'); + // 格式化车辆信息 + $formatCarInfo = [ + 'displacement' => $displacement, + 'gearbox' => $gearbox + ]; + + // 整理车辆信息 + $info['used_car_id'] = $this->getLastIdFromUrl($url); + $info['car_id'] = $carId; + $info['url'] = $url; + $info['title'] = $title; + $info['price'] = str_replace('万', '', $this->decodeDongchediPrice($price)); + $info['new_price'] = str_replace('万', '', $newPrice); + $info['price_difference'] = str_replace('万', '', $priceDifference); + $info['condition_desc'] = $conditionDesc; + $info['images'] = json_encode($images); + $info['car_id_url'] = $carIdUrl; + $info['used_detail'] = $jsonText; + $info['format_shop_info'] = json_encode($formatShopInfo); + $info['fuel_form'] = $resultFuelForm; + $info['classify_energy'] = $this->detectEnergyType($resultFuelForm); + $info['usage_mileage'] = $usageMileage; + $info['brand_name'] = $brandName; + $info['series_name'] = $seriesName; + $info['car_name'] = $carName; + $info['year'] = $year; + $info['format_car_info'] = json_encode($formatCarInfo); + + return $info; + } + + /** + * 获取配置信息 + */ + public function getConfigDetail(string $html = '') + { + $ql = QueryList::html($html); + + $jsonText = $ql->find('#__NEXT_DATA__')->text(); + + $raw = json_decode($jsonText, true); + $properties = $raw['props']['pageProps']['rawData']['properties'] ?? []; + $carInfo = $raw['props']['pageProps']['rawData']['car_info'][0] ?? []; + + $valuesMap = $this->buildValuesMapFromCarInfo($carInfo); + $groups = $this->buildCarSpecGroups($properties, $valuesMap); + + $data['format_car_info'] = [ + 'market_time' => $this->getFieldValue($groups, 'market_time'), // 上市时间 + 'driver_form' => $this->getFieldValue($groups, 'driver_form'), // 驱动形式 + 'seat_count' => $this->getFieldValue($groups, 'seat_count'), // 座位数 + 'engine_description' => $this->getFieldValue($groups, 'engine_description'), // 发动机 + 'engine_max_power_horsepower' => $this->getFieldValue($groups, 'engine_max_power_horsepower'), // 发动机最大功率 + 'electric_description' => $this->getFieldValue($groups, 'electric_description'), //电动机 + 'battery_capacity' => $this->getFieldValue($groups, 'battery_capacity'), //电池容量 + 'battery_type' => $this->getFieldValue($groups, 'battery_type'), // 电池类型 + 'recharge_mileage' => $this->getFieldValue($groups, 'recharge_mileage') // 续航里程 + ]; + $data['groups'] = $groups; + return $data; + } + + /** + * 从车辆配置 JSON 数据中获取能源类型 + * + * @param array $data JSON 数据 + * @return string|null 能源类型,如 '油电混合'、'纯电动'、 + */ + public function getFuelFormByConfigInfo(array $data) + { + $fuelForm = null; + foreach ($data[0]['items'] as $item) { + if ($item['key'] === 'fuel_form') { + $fuelForm = $item['value']; + break; + } + } + return $fuelForm; + } + + /** + * 从车辆配置 JSON 数据中根据字段 key 获取对应的 value + * + * @param string|array $data JSON 字符串 或 已解析的数组 + * @param string $field 要查找的字段 key(如 'fuel_form') + * @return string|null 找到则返回 value,否则返回 null + */ + public function getFieldValue($data, string $field) + { + // 如果传入的是 JSON 字符串,先解码 + if (is_string($data)) { + $data = json_decode($data, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return null; // JSON 格式错误 + } + } + + // 遍历第一层(假设结构是 [ { "items": [...] } ]) + foreach ($data as $group) { + if (isset($group['items']) && is_array($group['items'])) { + foreach ($group['items'] as $item) { + if (isset($item['key']) && $item['key'] === $field) { + return $item['value'] ?? null; + } + } + } + } + + return null; // 未找到 + } + + /** + * 从 other_params 数组中根据 name 获取对应的 value + * + * @param array $otherParams 格式为 [ ['name' => 'xxx', 'value' => 'yyy'], ... ] + * @param string $name 要查找的字段名称(如 '上牌地') + * @return string|null 找到则返回 value,否则返回 null + */ + public function getOtherParamValue(array $otherParams, string $name): ?string + { + foreach ($otherParams as $item) { + if (isset($item['name']) && $item['name'] === $name) { + return $item['value'] ?? null; + } + } + return null; + } + + /** + * 根据给定字符串判断车辆能源类型 + * + * 返回: + * - RE 新能源(纯电、插电混动、增程、氢燃料等) + * - CE 传统能源(汽油、柴油、天然气、非插电混动等) + * - UNKNOWN 无法判断 + * + * @param string $text 例如:'2022款 某某牌 2.0L 油电混合 双擎' + * @return string + */ + public function detectEnergyType(string $text): string + { + if (function_exists('mb_internal_encoding')) { + mb_internal_encoding('UTF-8'); + } + + // 明确定义的新能源关键字(纯电 / 插电 / 增程 / 氢 / 太阳能 等) + $newEnergyKeywords = [ + // 纯电 + '纯电', '纯电动', '电动车', 'BEV', + // 插电式混合 + '插电混动', '插电式混合', '插电式', '插电', 'PHEV', + // 增程式 + '增程式', '增程', 'REEV', + // 氢能 / 燃料电池 + '氢能', '氢燃料', '燃料电池', 'FCEV', + // 其它 + '太阳能', + ]; + + // 传统能源关键字(包括燃油/燃气) + $traditionalKeywords = [ + '汽油', + '柴油', + '天然气', 'LNG', 'CNG', + ]; + + // 普通混动关键字(按你的要求,统一归为传统能源) + $hybridKeywords = [ + '油电混合', '油电混动', '油电', + '混动', // 不带“插电”的混动 + '双擎', // 丰田常见表达 + 'HEV', + ]; + + // 辅助:不区分大小写的包含判断,优先 mb_stripos + $contains = function (string $haystack, string $needle): bool { + if ($needle === '') { + return false; + } + if (function_exists('mb_stripos')) { + return mb_stripos($haystack, $needle) !== false; + } + return stripos($haystack, $needle) !== false; + }; + + // 1. 先判断“明确新能源”关键词(插电、增程、氢、纯电等) + foreach ($newEnergyKeywords as $kw) { + if ($contains($text, $kw)) { + return 'RE'; + } + } + + // 2. 再判断“普通油电混动”——按照你的业务,一律归为传统能源 + foreach ($hybridKeywords as $kw) { + if ($contains($text, $kw)) { + return 'CE'; + } + } + + // 3. 再判断传统能源(纯燃油/气) + foreach ($traditionalKeywords as $kw) { + if ($contains($text, $kw)) { + return 'CE'; + } + } + + // 4. 都没匹配上 + return 'UNKNOWN'; + } + + /** + * 二手信息入库 + */ + public function saveUsedcarBasicToDb(array $data = []) + { + $data['created_at'] = time(); + $data['updated_at'] = time(); + return Db::table('usedcar_crawl_results')->insertGetId($data); + } + + /** + * 更新信息入库 + */ + public function saveUsedcarConfigToDb(array $data = [], int $id = 0) + { + $info = Db::table('usedcar_crawl_results')->find($id); + + if (!$info) { + return false; + } + + $groups = $data['groups']; + $formatCarInfo = array_merge($data['format_car_info'], json_decode($info->format_car_info, true)); + + return Db::table('usedcar_crawl_results') + ->where('id', $id) + ->update([ + 'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE), + 'config_detail' => json_encode($groups, JSON_UNESCAPED_UNICODE), + 'updated_at' => time() + ]); + } + + /** + * 把 car_info 扁平成一个 [key => ['value' => xxx]] 的 map + * + * @param array $carInfo rawData['car_info'][0] + * @return array + */ + public function buildValuesMapFromCarInfo(array $carInfo): array + { + $valuesMap = []; + + // 1) car_info 顶层的一些字段(official_price、series_name 等) + foreach ($carInfo as $k => $v) { + if ($k === 'info' || is_array($v)) { + continue; + } + $valuesMap[$k] = ['value' => $v]; + } + + // 2) info 里的配置项 + if (!empty($carInfo['info']) && is_array($carInfo['info'])) { + foreach ($carInfo['info'] as $k => $v) { + if (is_array($v) && array_key_exists('value', $v)) { + $valuesMap[$k] = $v; + } + } + } + + return $valuesMap; + } + + /** + * 根据 type 重新分组: + * - type=0,2 为分组(左侧分类) + * - 其它 type 都视为该分组下的字段 + * + * @param array $properties rawData['properties'] + * @param array $valuesMap 来自 buildValuesMapFromCarInfo() + * @return array + */ + public function buildCarSpecGroups(array $properties, array $valuesMap): array + { + $groups = []; + $currentKey = null; // 当前分组 key + + foreach ($properties as $item) { + $type = (int)($item['type'] ?? 1); + $key = $item['key'] ?? ''; + $text = $item['text'] ?? ''; + $subList = $item['sub_list'] ?? null; + + // ---------- 1) 分类字段:type = 0 / 2 ---------- + if (in_array($type, [0, 2], true)) { + $currentKey = $key ?: ($text ?: ('group_' . count($groups))); + + if (!isset($groups[$currentKey])) { + $groups[$currentKey] = [ + 'group_key' => $currentKey, + 'group_text' => $text, + 'group_type' => $type, + 'items' => [], + ]; + } + + continue; + } + + // ---------- 2) 普通字段:挂到最近的分类下 ---------- + if ($currentKey === null) { + // 如果一开始就没有遇到分类,给一个默认分组 + $currentKey = '_default'; + $groups[$currentKey] = [ + 'group_key' => '_default', + 'group_text' => '其他', + 'group_type' => -1, + 'items' => [], + ]; + } + + // 取 value + $value = ''; + if ($key && isset($valuesMap[$key])) { + $value = trim((string)($valuesMap[$key]['value'] ?? '')); + } + + $node = [ + 'key' => $key, + 'text' => $text, + 'value' => $value, + ]; + + // ---------- 3) sub_list 子项(有就当成 children) ---------- + if (is_array($subList) && !empty($subList)) { + $children = []; + foreach ($subList as $sub) { + $subKey = $sub['key'] ?? ''; + $subText = $sub['text'] ?? ''; + $subValue = ''; + + if ($subKey && isset($valuesMap[$subKey])) { + $subValue = trim((string)($valuesMap[$subKey]['value'] ?? '')); + } + + $children[] = [ + 'key' => $subKey, + 'text' => $subText, + 'value' => $subValue, + ]; + } + + $node['children'] = $children; + } + + $groups[$currentKey]['items'][] = $node; + } + + // 你可以按需返回关联数组或索引数组,这里转成索引数组方便前端遍历 + return array_values($groups); + } + + /** + * 转义价格 + */ + protected function decodeDongchediPrice(string $str): string + { + // 映射表:PUA 字符 => 数字 / 万 + $map = [ + "\u{E53D}" => '1', + "\u{E3F0}" => '2', + "\u{E422}" => '3', + "\u{E42C}" => '4', + "\u{E49C}" => '5', + "\u{E42B}" => '6', + "\u{E4FE}" => '7', + "\u{E548}" => '8', + "\u{E4C8}" => '9', + "\u{E453}" => '0', + "\u{E45F}" => '万', + ]; + + return strtr($str, $map); + } + + /** + * 获取URL最后ID + */ + protected function getLastIdFromUrl($url) + { + // 方法1:使用正则表达式提取最后的数字ID + if (preg_match('/\/(\d+)$/', $url, $matches)) { + return $matches[1]; + } + return false; + } + + /** + * 下载单张图片 + * + * @param string $url 图片完整 URL + * @param string $saveDir 保存目录(如 /www/usedcar_images/2025-11-20) + * @param string|null $filename 指定文件名(可选,不含路径),为空则自动生成 + * @param array $headers 额外 HTTP 头(可选) + * + * @return string|null 成功返回保存路径,失败返回 null + */ + protected function downloadImage(string $url, string $saveDir, ?string $filename = null, array $headers = []): ?string + { + // 1. 目录检查 & 创建 + if (!is_dir($saveDir)) { + if (!mkdir($saveDir, 0777, true) && !is_dir($saveDir)) { + echo "[Image] 创建目录失败:{$saveDir}\n"; + return null; + } + } + + // 2. 自动生成文件名(基于 URL md5 + 原扩展名) + if ($filename === null) { + $path = parse_url($url, PHP_URL_PATH); // /xxx/xxx.jpg + $ext = pathinfo($path, PATHINFO_EXTENSION); // jpg 或 webp 之类 + + if ($ext === '') { + $ext = 'jpg'; // 没有扩展名时兜底 + } + + $filename = md5($url) . '.' . $ext; + } + + $savePath = rtrim($saveDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename; + + // 3. 组装 HTTP 头 + $defaultHeaders = [ + 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + . 'AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Chrome/120.0.0.0 Safari/537.36', + 'Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + 'Referer: https://www.dongchedi.com/', // 很多图床需要 Referer + ]; + + foreach ($headers as $h) { + $defaultHeaders[] = $h; + } + + // 4. 使用 cURL 下载 + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_HTTPHEADER => $defaultHeaders, + ]); + + $data = curl_exec($ch); + $errNo = curl_errno($ch); + $errMsg = curl_error($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($errNo !== 0) { + echo "[Image] 下载失败(cURL):{$url} | {$errNo} {$errMsg}\n"; + return null; + } + + if ($code !== 200 || $data === false || $data === '') { + echo "[Image] 下载失败(HTTP {$code}):{$url}\n"; + return null; + } + + // 5. 写入本地文件 + $bytes = file_put_contents($savePath, $data); + if ($bytes === false) { + echo "[Image] 保存失败:{$savePath}\n"; + return null; + } + + echo "[Image] 下载成功:{$url} => {$savePath}\n"; + + return $savePath; + } + + /** + * 批量下载相册 + * + * @param array $urls 图片 URL 数组 + * @param string $saveDir 保存目录 + * + * @return array [原始 URL => 保存路径或 null] + */ + protected function downloadImageAlbum(array $urls, string $saveDir): array + { + $result = []; + + foreach ($urls as $url) { + $url = trim($url); + if ($url === '') { + continue; + } + + // 如果存在 // 开头的相对协议,补全 + if (strpos($url, '//') === 0) { + $url = 'https:' . $url; + } + + $path = $this->downloadImage($url, $saveDir); + $result[$url] = $path; + } + + return $result; + } +} diff --git a/app/common/utils/crawl/service/UsedCarCrawl_bak2.php b/app/common/utils/crawl/service/UsedCarCrawl_bak2.php new file mode 100644 index 0000000..2612baa --- /dev/null +++ b/app/common/utils/crawl/service/UsedCarCrawl_bak2.php @@ -0,0 +1,798 @@ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept-Language' => 'zh-CN,zh;q=0.9', + 'Referer' => 'https://www.dongchedi.com/' + ]; + + // Redis队列键名 + private const REDIS_QUEUE_KEY = 'usedcar:urls'; + + // 能源类型常量 + public const ENERGY_TYPE_RENEWABLE = 'RE'; + public const ENERGY_TYPE_CONVENTIONAL = 'CE'; + public const ENERGY_TYPE_UNKNOWN = 'UNKNOWN'; + + /** + * 插入队列url + * + * @param string $url URL地址 + * @param string $scene 场景:usedcar(二手信息) config(配置信息) + * @param int $id 记录ID + * @return int + */ + public function pushUrl(string $url = '', string $scene = 'usedcar', int $id = 0): int + { + $data = [ + 'scene' => $scene, + 'url' => $url, + 'id' => $id + ]; + + return Redis::lpush(self::REDIS_QUEUE_KEY, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + /** + * 获取队列url + * + * @return array|null + */ + public function popUrl(): ?array + { + $data = Redis::rPop(self::REDIS_QUEUE_KEY); + if (!$data) { + return null; + } + + return json_decode($data, true); + } + + /** + * 执行抓取任务 + * + * @return bool + * @throws BusinessException + */ + public function execute(): bool + { + try { + $task = $this->popUrl(); + + if (!$task) { + return false; + } + + $html = $this->fetchHtml($task['url']); + + switch ($task['scene']) { + case 'usedcar': + $info = $this->parseUsedCarDetail($html, $task['url']); + $id = $this->saveUsedCarBasicInfo($info); + $this->uploadAndRemove(json_decode($info['images'], true), public_path('uploads/usedcar/' . $id), $info['used_car_id'], $id); + $this->pushUrl($info['car_id_url'], 'config', $id); + break; + + case 'config': + $data = $this->parseConfigDetail($html); + $this->saveUsedCarConfigInfo($data, $task['id']); + break; + + default: + throw new BusinessException("未知的任务场景: " . $task['scene']); + } + + return true; + } catch (GuzzleException $e) { + throw new BusinessException("网络请求失败: " . $e->getMessage()); + } catch (Throwable $th) { + throw new BusinessException("执行任务出错: " . $th->getMessage() . ' | 行号: ' . $th->getLine() . ' | 文件: ' . $th->getFile()); + } + } + + /** + * 获取网页HTML内容 + * + * @param string $url + * @return string + * @throws GuzzleException + */ + public function fetchHtml(string $url): string + { + $client = new Client(); + $response = $client->request('GET', $url, [ + 'headers' => self::DEFAULT_HEADERS + ]); + return (string)$response->getBody(); + } + + /** + * 解析二手车详情页信息 + * + * @param string $html + * @param string $url + * @return array + */ + public function parseUsedCarDetail(string $html = '', string $url = ''): array + { + $ql = QueryList::html($html); + + $title = $ql->find('h1')->text(); + $price = $ql->find('.head-info_price-wrap__Y4bxi > p')->text(); + $pageText = $ql->find('body')->text(); + $jsonText = $ql->find('#__NEXT_DATA__')->text(); + + // 解析JSON数据 + $jsonArray = json_decode($jsonText, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new BusinessException("JSON解析失败: " . json_last_error_msg()); + } + + // 车辆相册 + $images = $jsonArray['props']['pageProps']['skuDetail']['head_images'] ?? []; + + // 车辆ID + $carId = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_id'] ?? 0; + + // 车辆年份 + $year = $jsonArray['props']['pageProps']['skuDetail']['car_info']['year'] ?? 0; + + // 车辆ID URL + $carIdUrl = $carId ? ('https://www.dongchedi.com/auto/params-carIds-' . $carId) : ''; + + // 能源类型 + $fuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['power']['fuel_form'] ?? ''; + $newFuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['new_energy_power']['fuel_form'] ?? ''; + $resultFuelForm = $fuelForm === '' ? $newFuelForm : $fuelForm; + + // 使用里程 + $importantText = $jsonArray['props']['pageProps']['skuDetail']['important_text'] ?? ''; + $importantTextArray = explode(' | ', $importantText); + $usageMileage = str_replace('万公里', '', isset($importantTextArray[1]) ? $importantTextArray[1] : ''); + + // 车辆名称 品牌、车系、车型 + $brandName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['brand_name'] ?? ''; + $seriesName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['series_name'] ?? ''; + $carName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_name'] ?? ''; + + // 商家信息 + $shopInfo = $jsonArray['props']['pageProps']['skuDetail']['shop_info'] ?? []; + $formatShopInfo = [ + 'shop_id' => $shopInfo['shop_id'] ?? 0, + 'shop_name' => $shopInfo['shop_short_name'] ?? '', + 'shop_address' => $shopInfo['shop_address'] ?? '' + ]; + + // 简介参数 + $otherParams = $jsonArray['props']['pageProps']['skuDetail']['other_params'] ?? []; + // 排量 + $displacement = $this->getParamValue($otherParams, '排量'); + // 变速箱 + $gearbox = $this->getParamValue($otherParams, '变速箱'); + // 格式化车辆信息 + $formatCarInfo = [ + 'displacement' => $displacement, + 'gearbox' => $gearbox + ]; + + // 提取新车指导价 + $newPrice = $this->extractPatternValue($pageText, '/新车指导价[::]\s*([^\r\n万]*万)/u'); + + // 提取比新车省金额 + $priceDifference = $this->extractPatternValue($pageText, '/比新车省[::]\s*([^\r\n万]*万)/u'); + + // 提取车况介绍 + $conditionDesc = ''; + if ($pageText) { + if (preg_match('/车况介绍\s*(.*?)\s*推荐理由/su', $pageText, $m)) { + $conditionDesc = trim($m[1]); + } + } + + // 整理车辆信息 + return [ + 'used_car_id' => $this->extractIdFromUrl($url), + 'car_id' => $carId, + 'url' => $url, + 'title' => $title, + 'price' => str_replace('万', '', $this->decodePrice($price)), + 'new_price' => str_replace('万', '', $newPrice), + 'price_difference' => str_replace('万', '', $priceDifference), + 'condition_desc' => $conditionDesc, + 'images' => json_encode($images, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'car_id_url' => $carIdUrl, + 'used_detail' => $jsonText, + 'format_shop_info' => json_encode($formatShopInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'fuel_form' => $resultFuelForm, + 'classify_energy' => $this->detectEnergyType($resultFuelForm), + 'usage_mileage' => $usageMileage, + 'brand_name' => $brandName, + 'series_name' => $seriesName, + 'car_name' => $carName, + 'year' => $year, + 'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ]; + } + + /** + * 解析配置详情页信息 + * + * @param string $html + * @return array + */ + public function parseConfigDetail(string $html = ''): array + { + $ql = QueryList::html($html); + $jsonText = $ql->find('#__NEXT_DATA__')->text(); + + $raw = json_decode($jsonText, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new BusinessException("配置页面JSON解析失败: " . json_last_error_msg()); + } + + $properties = $raw['props']['pageProps']['rawData']['properties'] ?? []; + $carInfo = $raw['props']['pageProps']['rawData']['car_info'][0] ?? []; + + $valuesMap = $this->buildValuesMap($carInfo); + $groups = $this->buildCarSpecGroups($properties, $valuesMap); + + return [ + 'format_car_info' => [ + 'market_time' => $this->getFieldValue($groups, 'market_time'), // 上市时间 + 'driver_form' => $this->getFieldValue($groups, 'driver_form'), // 驱动形式 + 'seat_count' => $this->getFieldValue($groups, 'seat_count'), // 座位数 + 'engine_description' => $this->getFieldValue($groups, 'engine_description'), // 发动机 + 'engine_max_power_horsepower' => $this->getFieldValue($groups, 'engine_max_power_horsepower'), // 发动机最大功率 + 'electric_description' => $this->getFieldValue($groups, 'electric_description'), //电动机 + 'battery_capacity' => $this->getFieldValue($groups, 'battery_capacity'), //电池容量 + 'battery_type' => $this->getFieldValue($groups, 'battery_type'), // 电池类型 + 'recharge_mileage' => $this->getFieldValue($groups, 'recharge_mileage') // 续航里程 + ], + 'groups' => $groups + ]; + } + + /** + * 从车辆配置中获取能源类型 + * + * @param array $data JSON数据 + * @return string|null 能源类型 + */ + public function getFuelFormByConfig(array $data): ?string + { + if (empty($data[0]['items'])) { + return null; + } + + foreach ($data[0]['items'] as $item) { + if (isset($item['key']) && $item['key'] === 'fuel_form') { + return $item['value'] ?? null; + } + } + + return null; + } + + /** + * 从数据中根据字段key获取值 + * + * @param mixed $data 数据 + * @param string $field 字段名 + * @return string|null + */ + public function getFieldValue($data, string $field): ?string + { + // 如果传入的是 JSON 字符串,先解码 + if (is_string($data)) { + $data = json_decode($data, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + } + + // 如果不是数组,直接返回null + if (!is_array($data)) { + return null; + } + + // 遍历寻找对应字段 + foreach ($data as $group) { + if (isset($group['items']) && is_array($group['items'])) { + foreach ($group['items'] as $item) { + if (isset($item['key']) && $item['key'] === $field) { + return $item['value'] ?? null; + } + + // 检查子元素 + if (isset($item['children']) && is_array($item['children'])) { + foreach ($item['children'] as $child) { + if (isset($child['key']) && $child['key'] === $field) { + return $child['value'] ?? null; + } + } + } + } + } + } + + return null; + } + + /** + * 从参数列表中获取指定名称的值 + * + * @param array $params 参数列表 + * @param string $name 名称 + * @return string|null + */ + public function getParamValue(array $params, string $name): ?string + { + foreach ($params as $item) { + if (isset($item['name']) && $item['name'] === $name) { + return $item['value'] ?? null; + } + } + return null; + } + + /** + * 判断车辆能源类型 + * + * @param string $text 描述文本 + * @return string 能源类型标识 + */ + public function detectEnergyType(string $text): string + { + if (function_exists('mb_internal_encoding')) { + mb_internal_encoding('UTF-8'); + } + + // 明确定义的新能源关键字 + $newEnergyKeywords = [ + // 纯电 + '纯电', '纯电动', '电动车', 'BEV', + // 插电式混合 + '插电混动', '插电式混合', '插电式', '插电', 'PHEV', + // 增程式 + '增程式', '增程', 'REEV', + // 氢能 / 燃料电池 + '氢能', '氢燃料', '燃料电池', 'FCEV', + // 其它 + '太阳能', + ]; + + // 传统能源关键字 + $traditionalKeywords = [ + '汽油', + '柴油', + '天然气', 'LNG', 'CNG', + ]; + + // 普通混动关键字(归为传统能源) + $hybridKeywords = [ + '油电混合', '油电混动', '油电', + '混动', // 不带"插电"的混动 + '双擎', // 丰田常见表达 + 'HEV', + ]; + + // 辅助函数:不区分大小写的包含判断 + $contains = function (string $haystack, string $needle): bool { + if ($needle === '') { + return false; + } + if (function_exists('mb_stripos')) { + return mb_stripos($haystack, $needle) !== false; + } + return stripos($haystack, $needle) !== false; + }; + + // 1. 先判断"明确新能源"关键词 + foreach ($newEnergyKeywords as $kw) { + if ($contains($text, $kw)) { + return self::ENERGY_TYPE_RENEWABLE; + } + } + + // 2. 再判断"普通油电混动" + foreach ($hybridKeywords as $kw) { + if ($contains($text, $kw)) { + return self::ENERGY_TYPE_CONVENTIONAL; + } + } + + // 3. 再判断传统能源 + foreach ($traditionalKeywords as $kw) { + if ($contains($text, $kw)) { + return self::ENERGY_TYPE_CONVENTIONAL; + } + } + + // 4. 都没匹配上 + return self::ENERGY_TYPE_UNKNOWN; + } + + /** + * 保存二手车基本信息到数据库 + * + * @param array $data 数据 + * @return int 插入记录ID + */ + public function saveUsedCarBasicInfo(array $data): int + { + $data['created_at'] = time(); + $data['updated_at'] = time(); + return Db::table('usedcar_crawl_results')->insertGetId($data); + } + + /** + * 更新配置信息到数据库 + * + * @param array $data 配置数据 + * @param int $id 记录ID + * @return bool|int + */ + public function saveUsedCarConfigInfo(array $data, int $id) + { + $record = Db::table('usedcar_crawl_results')->find($id); + + if (!$record) { + return false; + } + + $groups = $data['groups']; + $formatCarInfo = array_merge( + $data['format_car_info'], + json_decode($record->format_car_info, true) + ); + + return Db::table('usedcar_crawl_results') + ->where('id', $id) + ->update([ + 'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'config_detail' => json_encode($groups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'updated_at' => time() + ]); + } + + /** + * 构建值映射表 + * + * @param array $carInfo 车辆信息 + * @return array + */ + public function buildValuesMap(array $carInfo): array + { + $valuesMap = []; + + // 处理顶层字段 + foreach ($carInfo as $k => $v) { + if ($k === 'info' || is_array($v)) { + continue; + } + $valuesMap[$k] = ['value' => $v]; + } + + // 处理info中的配置项 + if (!empty($carInfo['info']) && is_array($carInfo['info'])) { + foreach ($carInfo['info'] as $k => $v) { + if (is_array($v) && array_key_exists('value', $v)) { + $valuesMap[$k] = $v; + } + } + } + + return $valuesMap; + } + + /** + * 构建车辆规格分组 + * + * @param array $properties 属性列表 + * @param array $valuesMap 值映射表 + * @return array + */ + public function buildCarSpecGroups(array $properties, array $valuesMap): array + { + $groups = []; + $currentKey = null; + + foreach ($properties as $item) { + $type = (int)($item['type'] ?? 1); + $key = $item['key'] ?? ''; + $text = $item['text'] ?? ''; + $subList = $item['sub_list'] ?? null; + + // 分类字段:type = 0 / 2 + if (in_array($type, [0, 2], true)) { + $currentKey = $key ?: ($text ?: ('group_' . count($groups))); + + if (!isset($groups[$currentKey])) { + $groups[$currentKey] = [ + 'group_key' => $currentKey, + 'group_text' => $text, + 'group_type' => $type, + 'items' => [], + ]; + } + + continue; + } + + // 普通字段:挂到最近的分类下 + if ($currentKey === null) { + // 如果一开始就没有遇到分类,给一个默认分组 + $currentKey = '_default'; + $groups[$currentKey] = [ + 'group_key' => '_default', + 'group_text' => '其他', + 'group_type' => -1, + 'items' => [], + ]; + } + + // 取 value + $value = ''; + if ($key && isset($valuesMap[$key])) { + $value = trim((string)($valuesMap[$key]['value'] ?? '')); + } + + $node = [ + 'key' => $key, + 'text' => $text, + 'value' => $value, + ]; + + // 处理子项 + if (is_array($subList) && !empty($subList)) { + $children = []; + foreach ($subList as $sub) { + $subKey = $sub['key'] ?? ''; + $subText = $sub['text'] ?? ''; + $subValue = ''; + + if ($subKey && isset($valuesMap[$subKey])) { + $subValue = trim((string)($valuesMap[$subKey]['value'] ?? '')); + } + + $children[] = [ + 'key' => $subKey, + 'text' => $subText, + 'value' => $subValue, + ]; + } + + $node['children'] = $children; + } + + $groups[$currentKey]['items'][] = $node; + } + + // 转成索引数组方便前端遍历 + return array_values($groups); + } + + /** + * 解码懂车帝价格 + * + * @param string $str 加密字符串 + * @return string 解码后的字符串 + */ + protected function decodePrice(string $str): string + { + // 映射表:PUA 字符 => 数字 / 万 + $map = [ + "\u{E53D}" => '1', + "\u{E3F0}" => '2', + "\u{E422}" => '3', + "\u{E42C}" => '4', + "\u{E49C}" => '5', + "\u{E42B}" => '6', + "\u{E4FE}" => '7', + "\u{E548}" => '8', + "\u{E4C8}" => '9', + "\u{E453}" => '0', + "\u{E45F}" => '万', + ]; + + return strtr($str, $map); + } + + /** + * 从URL中提取ID + * + * @param string $url URL + * @return int|false + */ + protected function extractIdFromUrl(string $url) + { + if (preg_match('/\/(\d+)$/', $url, $matches)) { + return (int)$matches[1]; + } + return false; + } + + /** + * 提取符合正则表达式的值 + * + * @param string $text 文本 + * @param string $pattern 正则表达式 + * @return string + */ + protected function extractPatternValue(string $text, string $pattern): string + { + $value = ''; + if ($text) { + if (preg_match($pattern, $text, $m)) { + $value = trim(preg_replace('/[^0-9.]/', '', $m[1])); + } + } + return $value; + } + + /** + * 下载单张图片 + * + * @param string $url 图片完整URL + * @param string $saveDir 保存目录 + * @param string|null $filename 指定文件名 + * @param array $headers 额外HTTP头 + * @return string|null 成功返回保存路径,失败返回null + */ + public function downloadImage(string $url, string $saveDir, ?string $filename = null, array $headers = []): ?string + { + // 目录检查 & 创建 + if (!is_dir($saveDir)) { + if (!mkdir($saveDir, 0777, true) && !is_dir($saveDir)) { + echo "[Image] 创建目录失败:{$saveDir}\n"; + return null; + } + } + + // 自动生成文件名 + if ($filename === null) { + $path = parse_url($url, PHP_URL_PATH); + $ext = pathinfo($path, PATHINFO_EXTENSION); + + if ($ext === '') { + $ext = 'jpg'; + } + + $filename = md5($url) . '.' . $ext; + } + + $savePath = rtrim($saveDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename; + + // 组装HTTP头 + $defaultHeaders = [ + 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + 'Referer: https://www.dongchedi.com/', + ]; + + foreach ($headers as $h) { + $defaultHeaders[] = $h; + } + + // 使用cURL下载 + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_HTTPHEADER => $defaultHeaders, + ]); + + $data = curl_exec($ch); + $errNo = curl_errno($ch); + $errMsg = curl_error($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($errNo !== 0) { + // echo "[Image] 下载失败(cURL):{$url} | {$errNo} {$errMsg}\n"; + return null; + } + + if ($code !== 200 || $data === false || $data === '') { + // echo "[Image] 下载失败(HTTP {$code}):{$url}\n"; + return null; + } + + // 写入本地文件 + $bytes = file_put_contents($savePath, $data); + if ($bytes === false) { + // echo "[Image] 保存失败:{$savePath}\n"; + return null; + } + + // echo "[Image] 下载成功:{$url} => {$savePath}\n"; + + return $savePath; + } + + /** + * 批量下载相册 + * + * @param array $urls 图片URL数组 + * @param string $saveDir 保存目录 + * @return array [原始URL => 保存路径或null] + */ + public function downloadImageAlbum(array $urls, string $saveDir): array + { + $result = []; + + foreach ($urls as $url) { + $url = trim($url); + if ($url === '') { + continue; + } + + // 补全协议 + if (strpos($url, '//') === 0) { + $url = 'https:' . $url; + } + + $path = $this->downloadImage($url, $saveDir); + $result[$url] = $path; + } + + return $result; + } + + + public function uploadAndRemove($images, $path, $usedCarId, $id) + { + // 下载图片 + $filePatch = $this->downloadImageAlbum($images, $path); + + // 检查图片 + if (empty($filePatch)) { + return false; + } + + // 上传图片 + foreach ($filePatch as $url => $path) { + if ($path === null) { + continue; + } + + // 上传云端 + $param = [ + 'path' => $path, + 'scene' => 'usedcar_crawl', + 'category' => 'usedcar' + ]; + $up = new \app\common\service\UploadService(); + $result = $up->uploadLocalFile($param); + + // 保存图片信息 + UsedcarCrawlResultsImages::create([ + 'uc_id' => $id, + 'used_car_id' => $usedCarId, + 'original_name' => $result['original_name'], + 'file_path' => $result['file_name'], + ]); + + // 删除本地文件 + @unlink($path); + } + } +} \ No newline at end of file diff --git a/app/common/utils/email/Emails.php b/app/common/utils/email/Emails.php new file mode 100644 index 0000000..2679a2d --- /dev/null +++ b/app/common/utils/email/Emails.php @@ -0,0 +1,564 @@ +mailbox = $options['mailbox']; + $this->username = $options['username']; + $this->password = $options['password']; + $this->limit = $options['limit']; + $this->per_page = $options['per_page']; + $this->batch_size = $options['batch_size']; + } + } + + /** + * 将任意字符串清洗为有效 UTF-8,移除或替换无效字节 + */ + protected function sanitizeUtf8(string $text): string + { + return iconv('UTF-8', 'UTF-8//IGNORE', $text); + } + + /** + * 记录错误到日志 + */ + protected function logError(string $msg): void + { + $line = '[' . date('Y-m-d H:i:s') . '] ' . $msg . "\n"; + file_put_contents( + runtime_path('logs/email_errors.log'), + $line, + FILE_APPEND | LOCK_EX + ); + } + + /** + * 按编码解码并保留 "\r\n" + */ + protected function getMailBody($inbox, int $id, bool $isUid = false): string + { + $flags = $isUid ? FT_UID : 0; + $structure = imap_fetchstructure($inbox, $id, $flags); + + if (!empty($structure->parts)) { + // HTML 部分 + foreach ($structure->parts as $i => $part) { + if (isset($part->subtype) && strtolower($part->subtype) === 'html') { + $data = imap_fetchbody($inbox, $id, (string)($i+1), $flags); + return $this->decodeBody($data, $part->encoding); + } + } + // 文本部分 + foreach ($structure->parts as $i => $part) { + if (isset($part->subtype) && strtolower($part->subtype) === 'plain') { + $data = imap_fetchbody($inbox, $id, (string)($i+1), $flags); + return $this->decodeBody($data, $part->encoding); + } + } + // 原始 body + return imap_body($inbox, $id, $flags); + } + + return imap_body($inbox, $id, $flags); + } + + /** + * 按编码解码 + */ + protected function decodeBody(string $body, int $encoding): string + { + switch ($encoding) { + case 3: return imap_base64($body); + case 4: return imap_qprint($body); + default: return $body; + } + } + + /** + * 解析 HTML,找不到

Preview

则返回 null, + * 否则提取所有 Key: Value 为小写蛇形 key=>value 数组 + */ + protected function parseHtml(string $html): ?array + { + $doc = new \DOMDocument(); + libxml_use_internal_errors(true); + $doc->loadHTML('' . $html); + libxml_clear_errors(); + + $xpath = new \DOMXPath($doc); + + // 类型检查 + if ($xpath->query("//h2[text()='Preview']")->length === 0) { + return null; + } + + $nodes = $xpath->query("//div[span]"); + $data = []; + + /** @var \DOMElement $node */ + foreach ($nodes as $node) { + $span = $xpath->query("span", $node)->item(0); + if (! $span) { + continue; + } + + // 原始键名 + $key = trim(str_replace(':', '', $span->textContent)); + // 转成小写蛇形 + $col = strtolower(str_replace(' ', '_', $key)); + + // 获取值 + $a = $xpath->query(".//a", $node)->item(0); + if ($a && $a->hasAttribute('href')) { + $value = trim($a->getAttribute('href')); + } else { + $clone = $node->cloneNode(true); + $spanClone = $xpath->query(".//span", $clone)->item(0); + if ($spanClone) { + $spanClone->parentNode->removeChild($spanClone); + } + $value = trim($clone->textContent); + } + + $data[$col] = $value; + } + + return $data; + } + + /** + * 拉取邮件uid + */ + public function uids() + { + try { + $retryTimes = 3; + $retryDelay = 10; + $inbox = false; + $attempt = 0; + + $maxUid = Db::table('email_uids')->max('uid'); + + while ($attempt < $retryTimes) { + $attempt++; + flg("尝试第 {$attempt} 次连接 IMAP...", false); + + $inbox = @imap_open($this->mailbox, $this->username, $this->password); + + if ($inbox) { + break; + } + + $error = imap_last_error(); + flg("第 {$attempt} 次失败:{$error}"); + + // 若出现 too many login failures 立即暂停一段时间 + if (stripos($error, 'Too many login failures') !== false) { + flg("检测到登录失败次数过多,暂停 60 秒避免封禁"); + sleep(60); + } else { + sleep($retryDelay); + } + } + + if (!$inbox) { + flg("最终仍然连接失败:" . imap_last_error() . ""); + return false; + } + + // 拉取 UID 并入库 + flg("开始拉取 UID 并入库 …", false); + $uids = imap_search($inbox, 'ALL', SE_UID) ?: []; + + $uids = array_filter($uids, function($uid) use ($maxUid) { + return $uid > $maxUid; + }); + + sort($uids); + $now = time(); + $count = 0; + + foreach ($uids as $uid) { + try { + Db::table('email_uids')->insert([ + 'uid' => $uid, + 'fetched_at' => $now, + 'email' => $this->username + ]); + $count++; + flg("UID:".$uid." 已写入数据库", false); + } catch (\Throwable $e) { + flg("{$e->getMessage()}"); + } + } + + imap_close($inbox); + flg("✔ 获取到新询盘邮件 {$count} 条 ...", false); + return true; + } catch (\Throwable $th) { + flg(dpr($th)); + return false; + } + } + + /** + * 获取邮件详情 + */ + public function details() + { + try { + $inbox = @imap_open($this->mailbox, $this->username, $this->password); + if (!$inbox) { + flg("IMAP 连接失败:". imap_last_error() .""); + return false; + } + + // 依次拉取详情并写入 emails 表 + flg("依次拉取详情并写入 emails 表 …", false); + while (true) { + $pending = Db::table('email_uids') + ->where('status', 0) + ->orderBy('uid', 'desc') + ->limit(2) + ->get(); + + if (count($pending) === 0) { + flg("邮件UID的详情,已拉取完毕。", false); + break; + } + + flg("处理下一批 " . count($pending) . " 条 …", false); + + foreach ($pending as $row) { + $uid = $row->uid; + + // 包裹整行处理在 try/catch 中,任何异常都不会中断循环 + try { + flg("---- 开始处理 UID={$uid} ----", false); + + // 1. fetch overview + $ov = @imap_fetch_overview($inbox, $uid, FT_UID)[0] ?? null; + if (!$ov) { + $err = imap_last_error() ?: '未知错误'; + flg("UID={$uid} overview 失败:{$err}"); + continue; + } + $subject = mb_convert_encoding($ov->subject ?? '', 'UTF-8', 'GBK,UTF-8'); + $from = $ov->from ?? ''; + $date = $ov->date ?? ''; + + flg("UID={$uid} overview 成功,subject={$subject}", false); + + // 2. fetch body + $body = $this->getMailBody($inbox, $uid, true); + flg("UID={$uid} body 长度:" . strlen($body), false); + + // 3. sanitize & insert + $body = $this->sanitizeUtf8($body); + $now = time(); + + try { + Db::table('emails')->insert([ + 'uid' => $uid, + 'subject' => $subject, + 'from' => $from, + 'date' => $date, + 'body' => $body, + 'created_at' => $now, + 'updated_at' => $now, + ]); + flg("UID={$uid} 写入 emails 成功", false); + } catch (\Throwable $e) { + flg("UID={$uid} 写入失败:{$e->getMessage()}"); + continue; + } + + // 4. 标记已处理 + try { + Db::table('email_uids') + ->where('uid', $uid) + ->update([ + 'status' => 1, + 'processed_at' => $now, + ]); + flg("UID={$uid} 标记为已处理", false); + } catch (\Throwable $e) { + flg("UID={$uid} 标记状态失败:{$e->getMessage()}", false); + // 即使更新 status 失败,也继续下一条 + } + } catch (\Throwable $e) { + // 捕获任何非预期错误 + flg("UID={$uid} 处理异常:{$e->getMessage()}"); + continue; + } + } + + // 批次结束后稍作休眠,防止服务器过载 + sleep(1); + } + + imap_close($inbox); + flg("✔ 新邮件详情拉取完成 ...", false); + return true; + } catch (\Throwable $th) { + flg(dpr($th)); + return false; + } + } + + /** + * 邮件清洗处理 + */ + public function process() + { + try { + $page = 0; + + // 明确定义 parsed_contacts 表里要插入的所有列(顺序无所谓,但要全部包含) + $fields = [ + 'email_id', + 'date', + 'email', + 'name', + 'tel', + 'company', + 'country', + 'form_url', + 'message', + 'ip', + 'iplocation', + 'ilang', + 'mailto', + 'navigator_language', + 'navigator_platform', + 'screen_width', + 'screen_height', + 'ucountry', + ]; + + $maxEmailId = Db::table('email_parsed_result')->max('email_id'); + + while (true) { + $rows = Db::table('emails') + ->where('status', 0) + ->where('id', '>', $maxEmailId) + ->orderBy('id', 'asc') + ->limit($this->batch_size) + ->offset($page * $this->batch_size) + ->get(); + + if ($rows->isEmpty()) { + break; + } + + $toInsert = []; + foreach ($rows as $row) { + $parsed = $this->parseHtml($row->body); + if ($parsed === null) { + flg(" ↳ [{$row->id}] skip: not a contact-form HTML", false); + continue; + } + + // 严格按 $fields 顺序填充每条记录,缺失的字段补 null + $record = []; + foreach ($fields as $col) { + if ($col === 'email_id') { + $record[$col] = $row->id; + } else { + $record[$col] = $parsed[$col] ?? null; + } + } + + $toInsert[] = $record; + } + + if (!empty($toInsert)) { + Db::table('email_parsed_result')->insert($toInsert); + + $emailId = array_column($toInsert, 'email_id'); + Db::table('emails')->whereIn('id', $emailId)->update(['status' => 1]); + + flg("Inserted batch #" . ($page + 1) . ", count: " . count($toInsert), false); + } + + $page++; + } + + flg("✔ 邮件清洗完毕,等待分配 ...", false); + return true; + } catch (\Throwable $th) { + flg(dpr($th)); + return false; + } + } + + + public function fenpai() + { + try { + // 配置参数 + $batchSize = 100; // 每批处理量 + $delaySeconds = 2; // 每封邮件间隔秒数 + $throttleAfter = 50; // 每发送多少封后增加休息 + $throttleSeconds = 5; // 批量休息秒数 + + // 固定录单人 + $enterUserId = 75; + $enterUser = User::where('id', $enterUserId)->first(); + + // 固定渠道 + $channel = 12; + $channelInfo = InquiryChannel::where('id', $channel)->first(); + + // 从库实时获取 + $users = \app\common\model\User::where('corp_id', 3)->where('status', 1)->orderBy('id')->pluck('id')->toArray(); + + // 移除不参与分配人员 + $users = array_values(array_diff($users, [190])); + + // 初始化 UID 轮询器 + $uidRotator = new \app\common\utils\RedisUidRotator('email_parsed_result_20250804'); + $uidRotator->setUids($users, false); + + EmailParsedResult::where('status', 0) + ->chunkById($batchSize, function ($records) use ($delaySeconds, $throttleAfter, $throttleSeconds, $enterUser, $channelInfo, $uidRotator) { + $count = 0; + + foreach ($records as $record) { + try { + // 获取国家信息 + $countryName = $record->iplocation ? removeParentheses($record->iplocation) : '未知'; + $countryInfo = Country::where('cn_addr', $countryName)->first(); + + // 获取下一个分配的UID + $assignedUid = $uidRotator->next(); + + // 获取分配的用户信息 + $recipientInfo = User::where('id', $assignedUid)->where('status', 1)->first(); + if (!$recipientInfo) { + flg("用户ID=" . $assignedUid . " 已离职 跳过自动分配,需要手动移除 UID"); + + $assignedUid = $uidRotator->next(); + $recipientInfo = User::where('id', $assignedUid)->where('status', 1)->first(); + } + + // 生成录单 + $log = [ + 'order_no' => orderNum(), + 'enter_user_id' => $enterUser->id, + 'recipient' => $assignedUid, + 'quality' => 2, + 'channel' => $channelInfo->id, + 'email' => $record->email, + 'tel' => $record->tel, + 'whatsapp' => '', + 'country' => $countryInfo->id, + 'contacts' => $record->name, + 'content' => $record->message, + 'r_time' => time(), + 't_time' => time(), + 'status' => 2, + 'sync_crm' => 1 + ]; + $inquiryLogResult = InquiryLog::create($log); + if (!$inquiryLogResult) { + throw new BusinessException('录单失败~'); + } + + // 同步客户 + $crmCustomer = new CrmCustomer(); + $crmCustomer->sn = $inquiryLogResult->order_no; + $crmCustomer->corp_id = $recipientInfo->corp_id; + $crmCustomer->user_id = $assignedUid; + $crmCustomer->type = 1; + $crmCustomer->channel_id = $inquiryLogResult->channel; + $crmCustomer->country_id = $inquiryLogResult->country; + $crmCustomer->magnitude = $inquiryLogResult->quality; + $crmCustomer->email = $inquiryLogResult->email; + $crmCustomer->phone = $inquiryLogResult->tel; + $crmCustomer->WhatsApp = $inquiryLogResult->whatsapp; + $crmCustomer->contacts = $inquiryLogResult->contacts; + $crmCustomer->desc_collect = $inquiryLogResult->content; + $crmCustomer->enter_user_id = $inquiryLogResult->enter_user_id; + if (!$crmCustomer->save()) { + throw new BusinessException('同步失败~'); + } + + // 邮件模板 + // $data = [ + // 'order_no' => $inquiryLogResult->order_no, + // 'enter_user_id' => $enterUser->name, + // 'recipient' => $recipientInfo->name, + // 'quality' => (new \app\inquiry\service\InquiryLogService())->qualityName($inquiryLogResult->quality), + // 'channel' => $channelInfo->name, + // 'email' => $inquiryLogResult->email && $inquiryLogResult->email != '' ? $inquiryLogResult->email : ' - ', + // 'tel' => $inquiryLogResult->tel && $inquiryLogResult->tel != '' ? $inquiryLogResult->tel : ' - ', + // 'whatsapp' => $inquiryLogResult->whatsapp && $inquiryLogResult->whatsapp != '' ? $inquiryLogResult->whatsapp : ' - ', + // 'country' => $countryName, + // 'contacts' => $inquiryLogResult->contacts && $inquiryLogResult->contacts != '' ? $inquiryLogResult->contacts : ' - ', + // 'content' => $inquiryLogResult->content && $inquiryLogResult->content != '' ? $inquiryLogResult->content : ' - ', + // 'r_time' => $inquiryLogResult->r_time ? date('Y-m-d H:i:s', $inquiryLogResult->r_time) : ' - ', + // 't_time' => $inquiryLogResult->t_time ? date('Y-m-d H:i:s', $inquiryLogResult->t_time) : ' - ' + // ]; + // \app\common\utils\email\MailSend::send($recipientInfo->email, '【XYH】新的客户线索', replaceTpl($data)); + + // 9.2 修改消息发送 + $data = [ + 'id' => $inquiryLogResult->id + ]; + \Webman\RedisQueue\Redis::send('inquiry-to-crm-new', $data); + + // 更新状态 + $record->update(['status' => 1]); + flg('====== 编号:'.$inquiryLogResult->order_no.' 分配给:'.$recipientInfo->name.' email:'.$recipientInfo->email, false); + + // 每封邮件后延迟 + if ($delaySeconds > 0) { + sleep($delaySeconds); + } + + // 每发送一定数量后额外休息 + if (++$count % $throttleAfter === 0) { + sleep($throttleSeconds); + flg("已发送 {$count} 封邮件,休息 {$throttleSeconds} 秒", false); + } + + } catch (\Exception $e) { + flg("邮件发送失败 ID:{$record->id} - " . $e->getMessage()); + // 更新状态 + $record->update(['status' => 3]); + // 失败后也加入延迟,避免快速连续失败 + sleep($delaySeconds); + } + } + }); + } catch (\Throwable $th) { + flg(dpr($th)); + return false; + } + } +} \ No newline at end of file diff --git a/app/common/utils/email/MailSend.php b/app/common/utils/email/MailSend.php new file mode 100644 index 0000000..a5742d3 --- /dev/null +++ b/app/common/utils/email/MailSend.php @@ -0,0 +1,69 @@ + '/path/file', 'name' => '文件名']] + * @return bool + */ + public static function send($to, $subject, $body, $attachments = []) + { + $mail = new PHPMailer(true); + + try { + // 服务器配置 + $mail->isSMTP(); + $mail->Host = config('imap.smtp_xyh.host'); // SMTP服务器 + $mail->SMTPAuth = true; + $mail->Username = config('imap.smtp_xyh.username'); // 邮箱账号 + $mail->Password = config('imap.smtp_xyh.password'); // 邮箱密码/授权码 + $mail->SMTPSecure = config('imap.smtp_xyh.encryption'); // 加密方式 tls/ssl + $mail->Port = config('imap.smtp_xyh.port'); // 端口 + + // 发件人 + $mail->setFrom(config('imap.smtp_xyh.from_address'), config('imap.smtp_xyh.from_name')); + + // 收件人(支持单个或多个) + if (is_string($to)) { + $mail->addAddress($to); + } elseif (is_array($to)) { + foreach ($to as $email) { + $mail->addAddress($email); + } + } + + // 附件 + foreach ($attachments as $file) { + $mail->addAttachment($file['path'], $file['name'] ?? ''); + } + + // 内容 + $mail->isHTML(true); + $mail->CharSet = 'UTF-8'; + $mail->Encoding = 'base64'; + $mail->Subject = mb_encode_mimeheader($subject, 'UTF-8', 'B', "\r\n"); + $mail->Body = $body; + // $mail->AltBody = strip_tags($body); // 纯文本备用 + // $mail->AltBody = $altbody; + + $mail->send(); + return true; + } catch (Exception $e) { + Log::error("邮件发送失败: " . $e->getMessage()); + return false; + } + } +} \ No newline at end of file diff --git a/app/common/utils/excel/ExcelImportTrait.php b/app/common/utils/excel/ExcelImportTrait.php new file mode 100644 index 0000000..79fffa4 --- /dev/null +++ b/app/common/utils/excel/ExcelImportTrait.php @@ -0,0 +1,133 @@ +ossSignedUrl($objectKey); + $ret = ExcelReader::readSheetFromUrl($signedUrl, $sheet, $headerRow, $startRow); + return $ret['rows'] ?? []; + } + + /** + * 使用你的 OssService 生成私有签名 URL + * - 兼容你的现有写法:(new \app\common\service\OssService)->generatePrivateUrl($objectKey) + */ + protected function ossSignedUrl(string $objectKey): string + { + return (new \app\common\service\OssService())->generatePrivateUrl($objectKey); + } + + /** + * 将一行做字段映射 + 基础类型规范化(decimal/bool/date) + * - $headerMap:表头映射(源列名 => 目标字段名) + * - $decimalFields/$boolFields/$dateFields:需要做类型规范化的目标字段名列表 + */ + protected function mapAndNormalizeRow( + array $row, + array $headerMap, + array $decimalFields = [], + array $boolFields = [], + array $dateFields = [] + ): array { + $rec = ExcelReader::remap($row, $headerMap); + + foreach ($decimalFields as $f) { + if (array_key_exists($f, $rec)) { + $rec[$f] = ExcelReader::toDecimal($rec[$f]); // 建议已升级为“去货币符号/千分位/括号负数”的版本 + } + } + foreach ($boolFields as $f) { + if (array_key_exists($f, $rec)) { + $rec[$f] = ExcelReader::toBool($rec[$f]); + } + } + foreach ($dateFields as $f) { + if (array_key_exists($f, $rec)) { + $rec[$f] = ExcelReader::toDate($rec[$f]); + } + } + return $rec; + } + + /** + * 基于 VIN 的跳过规则:VIN 为空、或是“合计/总计”行 + * - $vinAliases:VIN 列的候选表头(如 ['车架号','VIN NO']) + */ + protected function shouldSkipByVinAliases(array $row, array $vinAliases = ['车架号', 'VIN NO']): bool + { + $vin = $this->pickFirstNonEmpty($row, $vinAliases); + if ($vin === null) return true; + return (bool)preg_match('/^(合计|总计)/u', $vin); + } + + /** + * 在多个候选键里取第一个非空字符串 + */ + protected function pickFirstNonEmpty(array $row, array $keys): ?string + { + foreach ($keys as $k) { + if (isset($row[$k])) { + $v = trim((string)$row[$k]); + if ($v !== '') return $v; + } + } + return null; + } + + /** + * 统一补齐 created_at / updated_at 时间戳(int(11)) + */ + protected function stamp(array &$rec): void + { + $now = time(); + $rec['created_at'] = $now; + $rec['updated_at'] = $now; + } + + /** + * 批量入库 + * - 返回 ['inserted'=>N, 'error'=>null|string] + * - 发生异常时不抛出,返回 error 字符串,便于上层统一处理 + */ + protected function batchInsert(string $table, array $rows): array + { + if (!$rows) return ['inserted' => 0, 'error' => null]; + try { + Db::table($table)->insert($rows); + return ['inserted' => count($rows), 'error' => null]; + } catch (\Throwable $e) { + return ['inserted' => 0, 'error' => $e->getMessage()]; + } + } +} diff --git a/app/common/utils/excel/ExcelReader.php b/app/common/utils/excel/ExcelReader.php new file mode 100644 index 0000000..e5f002a --- /dev/null +++ b/app/common/utils/excel/ExcelReader.php @@ -0,0 +1,292 @@ +[], 'rows'=>[]] + * - $headerRow:从 1 开始计数(Excel 行号) + * - $startRow:数据起始行(默认 headerRow+1) + * - 注意:$filePath 必须是“本地文件路径”;若为 URL,请改用 readSheetFromUrl() + */ + public static function readSheet(string $filePath, string $sheetName, int $headerRow = 1, ?int $startRow = null): array + { + self::assertRequirements(); + + if (!is_file($filePath)) { + throw new \RuntimeException("File not found: {$filePath}"); + } + + $reader = IOFactory::createReaderForFile($filePath); + $reader->setReadDataOnly(true); + $spreadsheet = $reader->load($filePath); + + $sheet = $spreadsheet->getSheetByName($sheetName); + if (!$sheet) { + $names = implode(' / ', $spreadsheet->getSheetNames()); + $spreadsheet->disconnectWorksheets(); + throw new \RuntimeException("Sheet '{$sheetName}' not found. Available: {$names}"); + } + + $highestRow = $sheet->getHighestDataRow(); + $highestCol = $sheet->getHighestDataColumn(); + $highestColIndex = Coordinate::columnIndexFromString($highestCol); + + // 读取表头(去除可能的 UTF-8 BOM) + $headers = []; + for ($col = 1; $col <= $highestColIndex; $col++) { + $coord = Coordinate::stringFromColumnIndex($col) . $headerRow; + $cell = $sheet->getCell($coord); + $raw = $cell ? $cell->getCalculatedValue() : null; + $text = is_null($raw) ? '' : trim((string)$raw); + // 去掉 BOM + if ($col === 1) { + $text = preg_replace('/^\xEF\xBB\xBF/u', '', $text); + } + $headers[$col] = $text === '' ? "COL_{$col}" : $text; + } + + // 读取数据 + $rows = []; + $firstDataRow = $startRow ?? ($headerRow + 1); + for ($row = $firstDataRow; $row <= $highestRow; $row++) { + $record = []; + $isEmpty = true; + + for ($col = 1; $col <= $highestColIndex; $col++) { + $coord = Coordinate::stringFromColumnIndex($col) . $row; + $cell = $sheet->getCell($coord); + $value = $cell ? $cell->getCalculatedValue() : null; + + // 日期单元格转换(使用 PhpSpreadsheet 的日期检测) + if ($cell && \PhpOffice\PhpSpreadsheet\Shared\Date::isDateTime($cell) && is_numeric($value)) { + $dt = \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($value); + $value = $dt ? $dt->format('Y-m-d') : $value; + } + + if (is_string($value)) { + $value = trim($value); + } + if ($value !== null && $value !== '') { + $isEmpty = false; + } + $record[$headers[$col]] = $value; + } + + if (!$isEmpty) { + $rows[] = $record; + } + } + + $spreadsheet->disconnectWorksheets(); + return ['headers' => array_values($headers), 'rows' => $rows]; + } + + /** + * 从 URL 读取指定 sheet(内部先下载到临时文件) + */ + public static function readSheetFromUrl(string $url, string $sheetName, int $headerRow = 1, ?int $startRow = null): array + { + $tmp = self::downloadToTemp($url); + try { + return self::readSheet($tmp, $sheetName, $headerRow, $startRow); + } finally { + @unlink($tmp); + } + } + + /** + * 按映射表将“源表头”=>“目标字段名”(snake_case) + * $map 形如:['车架号' => 'vin', '实际采购价' => 'actual_purchase_price', ...] + */ + public static function remap(array $row, array $map): array + { + $out = []; + foreach ($map as $from => $to) { + $out[$to] = $row[$from] ?? null; + } + return $out; + } + + /** 将一批行做 remap */ + public static function remapRows(array $rows, array $map): array + { + $out = []; + foreach ($rows as $row) { + $out[] = self::remap($row, $map); + } + return $out; + } + + /** + * 将“金额字符串”解析成 float(适配多币种/多地区写法) + * - 支持:¥ ¥ $ € £ …、AED/Dirham:dh / dhs / aed、阿语 د.إ + * - 支持:中文/全角标点、阿拉伯小数/千分位、细空格、撇号千分位 + * - 支持:括号负数 (xxx) 与显式负号 + * - 返回 null 表示无法解析 + */ + public static function toDecimal($v): ?float + { + if ($v === null || $v === '') return null; + if (is_numeric($v)) return (float)$v; + + $s = trim((string)$v); + + // 1) 统一常见 unicode 标点(全角/阿拉伯)与空格 + $s = strtr($s, [ + ',' => ',', '.' => '.', + '٫' => '.', '٬' => ',', + "\xC2\xA0" => ' ', // NBSP + "\xE2\x80\xAF" => ' ', // NNBSP(窄不换行空格) + ]); + + // 2) 括号负数,如 (dh1,234.56) + $negative = false; + if (preg_match('/^\s*\((.*)\)\s*$/u', $s, $m)) { + $negative = true; + $s = $m[1]; + } + + // 3) 去币种/符号:AED/Dirham(dh/dhs/aed/د.إ)& 其它常见符号/字母 + $s = preg_replace('/\b(dhs?|aed)\b/i', '', $s); // dh / dhs / aed + $s = str_replace(['د.إ', 'د', 'إ'], '', $s); // 常见阿语形式简化处理 + $s = preg_replace('/[A-Za-z¥¥$€£₽₺₩₹﷼]+/u', '', $s); // 其它字母/货币符号 + + // 4) 只保留 数字/点/逗号/减号/空格/撇号,然后去掉空格与撇号(千分位) + $s = preg_replace('/[^\d\.,\-\s\']+/u', '', $s); + $s = str_replace([' ', "'"], '', $s); + + // 5) 统一小数分隔符(判断最后出现的分隔符) + $lastComma = strrpos($s, ','); + $lastDot = strrpos($s, '.'); + + if ($lastComma !== false && $lastDot !== false) { + // 同时存在,最后出现者视为小数点 + if ($lastComma > $lastDot) { + // 欧式 1.234,56 + $s = str_replace('.', '', $s); // 去千分点 + $s = substr_replace($s, '.', $lastComma, 1); // 最后一个逗号改为小数点 + $s = str_replace(',', '', $s); // 其余逗号清空 + } else { + // 美式 1,234.56 + $s = str_replace(',', '', $s); + } + } elseif ($lastComma !== false) { + // 只有逗号 + $digitsAfter = strlen($s) - $lastComma - 1; + if (substr_count($s, ',') > 1) { + // 多个逗号基本都是千分位 + $s = str_replace(',', '', $s); + } else { + // 一个逗号:若逗号后 1~2 位,视为小数点,否则千分位 + $s = ($digitsAfter >= 1 && $digitsAfter <= 2) + ? str_replace(',', '.', $s) + : str_replace(',', '', $s); + } + } elseif ($lastDot !== false && substr_count($s, '.') > 1) { + // 多个点:保留最后一个为小数点,其他去掉(当作千分位) + $digitsAfter = strlen($s) - $lastDot - 1; + if ($digitsAfter >= 1 && $digitsAfter <= 2) { + $s = preg_replace('/\.(?=.*\.)/', '', $s); // 移除除最后一个外的点 + } else { + $s = str_replace('.', '', $s); + } + } + + // 6) 处理显式负号 + if (strpos($s, '-') !== false) { + $negative = true; + $s = str_replace('-', '', $s); + } + + // 7) 最终校验并转 float + if ($s === '' || !preg_match('/^\d+(\.\d+)?$/', $s)) { + return null; // 无法解析 + } + + $num = (float)$s; + return $negative ? -$num : $num; + } + + /** 规范化布尔:'是/否'、'Y/N'、'1/0'、'true/false' */ + public static function toBool($v): ?int + { + if ($v === null || $v === '') return null; + $s = mb_strtolower(trim((string)$v)); + if (in_array($s, ['是','y','yes','true','1','√','✅'], true)) return 1; + if (in_array($s, ['否','n','no','false','0','×','❌'], true)) return 0; + return null; + } + + /** 规范化日期(readSheet 已对 Excel 日期做过处理,这里再兜底) */ + public static function toDate($v): ?string + { + if ($v === null || $v === '') return null; + if (is_numeric($v)) { + // Excel 序列号兜底 + $ts = ExcelDate::excelToTimestamp($v); + return $ts ? date('Y-m-d', $ts) : null; + } + // 简单清洗 + $s = trim((string)$v); + // 常见格式直接返回(如 2025-11-04 / 2025/11/04) + if (preg_match('/^\d{4}[-\/]\d{1,2}[-\/]\d{1,2}$/', $s)) { + return str_replace('/', '-', $s); + } + return $s; // 留给上层决定是否再严格校验 + } + + /** + * 把远程 URL 流式下载到临时文件;返回临时路径 + */ + protected static function downloadToTemp(string $url, ?string $ext = null): string + { + $ext = $ext ? ('.' . ltrim($ext, '.')) : ''; + $tmp = sys_get_temp_dir() . '/excel_' . uniqid() . $ext; + $fp = fopen($tmp, 'w'); + if (!$fp) { + throw new \RuntimeException("Cannot open temp file: {$tmp}"); + } + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_FILE => $fp, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 300, + CURLOPT_CONNECTTIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_USERAGENT => 'Webman-ExcelReader/1.0', + ]); + $ok = curl_exec($ch); + $http = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $err = curl_error($ch); + curl_close($ch); + fclose($fp); + if ($ok === false || $http >= 400) { + @unlink($tmp); + throw new \RuntimeException("Download failed: HTTP {$http} {$err}"); + } + return $tmp; + } +} diff --git a/app/common/utils/pdf/controller/PdfController.php b/app/common/utils/pdf/controller/PdfController.php new file mode 100644 index 0000000..b647628 --- /dev/null +++ b/app/common/utils/pdf/controller/PdfController.php @@ -0,0 +1,76 @@ + '备货单', + 'order_no' => 'SO' . date('YmdHis'), + 'customer' => 'Magic Bridge Auto FZE', + ]); + + $driver = new MpdfDriver(); + $pdf = $driver->htmlToPdf($html, [ + // 若 HTML 里用到相对资源(img/css),给个基准目录 + 'asset_base' => public_path('assets/pdf'), + // 也可以单独传 CSS 文件(推荐把 CSS 放在 public/assets/pdf) + 'css_files' => [ + public_path('assets/pdf/stock-order.css'), + ], + 'header_html' => '
Company • Stock Order
', + 'footer_html' => '
{PAGENO} / {nbpg}
', + 'watermark_text' => '', // 'DRAFT' + ]); + + return new Response(200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="stock-order.pdf"', + ], $pdf); + } + + // ② 直接下载 + public function download(Request $request): Response + { + $html = '

发票

客户:永生花

'; + $pdf = (new MpdfDriver())->htmlToPdf($html); + return new Response(200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="invoice.pdf"', + ], $pdf); + } + + // ③ 保存到本地(或上传 OSS 前先落盘) + public function save(Request $request): Response + { + $tpl = base_path('resource/templates/invoice.html'); + $html = PdfHelper::loadTemplate($tpl, [ + 'invoice_no' => 'INV' . date('YmdHis'), + 'amount' => '12345.67', + ]); + + $pdf = (new MpdfDriver())->htmlToPdf($html, [ + 'css_files' => [ public_path('assets/pdf/invoice.css') ], + 'asset_base'=> public_path('assets/pdf'), + ]); + + $dir = runtime_path('pdf'); + if (!is_dir($dir)) mkdir($dir, 0777, true); + $path = $dir . '/invoice-' . date('YmdHis') . '.pdf'; + file_put_contents($path, $pdf); + + return json(['path' => $path]); + } +} diff --git a/app/common/utils/pdf/service/MpdfDriver.php b/app/common/utils/pdf/service/MpdfDriver.php new file mode 100644 index 0000000..d59427a --- /dev/null +++ b/app/common/utils/pdf/service/MpdfDriver.php @@ -0,0 +1,118 @@ + 'A4', + 'orientation' => 'L', + 'margin_top' => 12, + 'margin_bottom' => 12, + 'margin_left' => 10, + 'margin_right' => 10, + 'tempDir' => runtime_path('mpdf'), + 'header_html' => '', + 'footer_html' => '', + 'watermark_text'=> '', + 'css_files' => [], + 'default_font' => 'notosanssc', + 'font_dirs' => [public_path('fonts')], + 'font_data' => [ + 'notosanssc' => ['R'=>'NotoSansSC-Regular.ttf','B'=>'NotoSansSC-Bold.ttf'], + ], + 'asset_base' => '', + 'enable_html5_parser' => true, + ], $options); + + $defaultConfig = (new ConfigVariables())->getDefaults(); + $defaultFontConfig = (new FontVariables())->getDefaults(); + + $fontDirs = array_merge($defaultConfig['fontDir'], $opts['font_dirs']); + $fontData = array_merge($defaultFontConfig['fontdata'], $opts['font_data']); + + $mpdf = new Mpdf([ + 'format' => $opts['format'], + 'orientation' => $opts['orientation'], + 'margin_left' => $opts['margin_left'], + 'margin_right' => $opts['margin_right'], + 'margin_top' => $opts['margin_top'], + 'margin_bottom' => $opts['margin_bottom'], + 'tempDir' => $opts['tempDir'], + 'fontDir' => $fontDirs, + 'fontdata' => $fontData, + 'default_font' => $opts['default_font'], + 'enable_html5_parser' => $opts['enable_html5_parser'], + ]); + + if ($opts['header_html']) $mpdf->SetHTMLHeader($opts['header_html']); + if ($opts['footer_html']) $mpdf->SetHTMLFooter($opts['footer_html']); + + if (!empty($opts['watermark_text'])) { + $mpdf->SetWatermarkText($opts['watermark_text']); + $mpdf->showWatermarkText = true; + $mpdf->watermarkTextAlpha = 0.08; + } + + // 资源绝对化(如果你有相对路径的图片等) + $html = PdfHelper::absolutizeAssetUrls($html, $opts['asset_base']); + + // === 关键改动:把 抽出来,作为 HEADER_CSS 写入 === + [$inlineCss, $cleanHtml] = $this->extractCssAndCleanHtml($html); + + // 先写外部 css_files + if (!empty($opts['css_files'])) { + foreach ($opts['css_files'] as $cssFile) { + if (is_file($cssFile)) { + $mpdf->WriteHTML(file_get_contents($cssFile), HTMLParserMode::HEADER_CSS); + } + } + } + // 再写内联 的内容拼成一段 CSS,并把这些 ,DOTALL 支持跨行 + if (preg_match_all('/]*>(.*?)<\/style>/is', $html, $m)) { + foreach ($m[1] as $block) { + // 去掉 HTML 注释,以防注释把 CSS 包起来 + $block = preg_replace('//', '', $block); + $cssParts[] = trim($block); + } + // 从 HTML 删除所有 + + +
+ +
+

合塑实业-汽车事业部-备货单(客户下定)

+
+ 备货单号: + 业务员: +
+
+ + + + + + + + + + + + + + + + + + + + + +
阿里订单号/PI号: 日期: 清关目的国: 客户来源: 客户名称: 客户所在国:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0): ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
编号车型数量/台颜色/外颜色/内收款价总价官方指导价/二手挂牌价优惠利润
+ + + + 0): ?> + () + + + +
改装费FOB运费车价海运费佣金挂账港杂
+ 备注: + + +
+ 合同汇率: + + 合计到岸价: +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
清关人: 全款收齐日: 预计发运日期: 出运方式: 国内收货港: 贸易条款/清关目的港: /提车日期: 最迟到港时间:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
计划收款信息序号汇款国日期币种账户汇款人金额
+ + +
+ +
+ +
+ 业务员签字: + + + + + +
+ +
+ 备发货负责人签字: + + + + + +
+
+ + +
+
+ 组长签字: + + + + + +
+ +
+ 日期: + +
+
+ +
+
+ + +
+ + \ No newline at end of file diff --git a/app/common/utils/pdf/template/stockOrder copy.stub b/app/common/utils/pdf/template/stockOrder copy.stub new file mode 100644 index 0000000..43f887f --- /dev/null +++ b/app/common/utils/pdf/template/stockOrder copy.stub @@ -0,0 +1,326 @@ + + + + + 合塑实业-汽车事业部-备货单(客户下定) + + + +
+ +
+

合塑实业-汽车事业部-备货单(客户下定)

+
+ 备货单号: + 业务员: +
+
+ + + + + + + + + + + + + + + + + + + + + +
阿里订单号/PI号: 日期: 清关目的国: 客户来源: 客户名称: 客户所在国:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0): ?> + + + + + + + + + + + + + + + + + + + + + + +
编号车型数量/台颜色/外内饰改装官方指导价/二手挂牌价优惠单价总价报关价利润
+ + 0): ?> + () + +
+ 备注: +
+ 合同汇率: + + 合计到岸价: +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
清关人: 全款收齐日: 预计发运日期: 出运方式: 国内收货港: 贸易条款/清关目的港: /提车日期: 最迟到港时间:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
计划收款信息序号汇款国日期币种账户汇款人金额
+ + +
+ +
+ +
+ 业务员签字: + + + + + +
+ +
+ 备发货负责人签字: + + + + + +
+
+ + +
+
+ 组长签字: + + + + + +
+ +
+ 日期: + +
+
+ +
+
+ + +
+ + \ No newline at end of file diff --git a/app/common/utils/pdf/template/stockOrder.stub b/app/common/utils/pdf/template/stockOrder.stub new file mode 100644 index 0000000..807c3e3 --- /dev/null +++ b/app/common/utils/pdf/template/stockOrder.stub @@ -0,0 +1,379 @@ + + + + + 合塑实业-汽车事业部-备货单(客户下定) + + + +
+ +
+

合塑实业-汽车事业部-备货单

+
+ 备货单号: + 业务员: +
+
+ + + + + + + + + + + + + + + + + + + + + +
阿里订单号/PI号: 日期: 清关目的国: 客户来源: 客户名称: 客户所在国:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0): ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
编号车型数量颜色费用明细
+ + +

+ 0): ?> + 里程:km +
+ + VIN: + +
+ + + 外饰:

+ 内饰: +
手续费改装费配件费收款价总价官方指导价/二手挂牌价优惠利润
加价报关价FOB运费车价海运费佣金挂账港杂
备注 + + + +
+ 合同汇率: + + + 合计到岸价: + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
清关人: 全款收齐日: 预计发运日期: 出运方式: 国内收货港: 贸易条款: 清关目的港: 提车日期: 最迟到港时间:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
计划收款信息序号汇款国日期币种账户汇款人金额
+ + +
+ +
+ +
+ 业务员签字: + + + + + +
+ +
+ 备发货负责人签字: + + + + + +
+
+ + +
+
+ 组长签字: + + + + + +
+ +
+ 日期: + +
+
+ +
+
+ + +
+ + \ No newline at end of file diff --git a/app/common/utils/scaffold/Generate.php b/app/common/utils/scaffold/Generate.php new file mode 100644 index 0000000..2ed10a0 --- /dev/null +++ b/app/common/utils/scaffold/Generate.php @@ -0,0 +1,194 @@ +options = $options; + } + + public function doGenerate() + { + $fs = new Filesystem(); + + $templateDir = __DIR__ . '/templates/'; + + $resultStr = ''; + + if (!empty($this->options['controller'])) { + $controllerPath = __DIR__ . "/../../../{$this->options['controller']['module']}/controller/".$this->rName($this->options['controller']['name'])."Controller.php"; + $fs->mkdir(__DIR__ . "/../../../{$this->options['controller']['module']}/controller"); + $this->deleteFileIfExists($controllerPath); + $ControllerStub = $this->generateFile($templateDir, 'ControllerStub.stub', $controllerPath, 'controller'); + $resultStr .= $ControllerStub; + } + + if (!empty($this->options['service'])) { + $servicePath = __DIR__ . "/../../../{$this->options['service']['module']}/service/".$this->rName($this->options['service']['name'])."Service.php"; + $fs->mkdir(__DIR__ . "/../../../{$this->options['service']['module']}/service"); + $this->deleteFileIfExists($servicePath); + $ServiceStub = $this->generateFile($templateDir, 'ServiceStub.stub', $servicePath, 'service'); + $resultStr .= $ServiceStub; + } + + if (!empty($this->options['logic'])) { + $logicPath = __DIR__ . "/../../../{$this->options['logic']['module']}/logic/".$this->rName($this->options['logic']['name'])."Logic.php"; + $fs->mkdir(__DIR__ . "/../../../{$this->options['logic']['module']}/logic"); + $this->deleteFileIfExists($logicPath); + $LogicStub = $this->generateFile($templateDir, 'LogicStub.stub', $logicPath, 'logic'); + $resultStr .= $LogicStub; + } + + if (!empty($this->options['model'])) { + $modelPath = __DIR__ . "/../../../{$this->options['model']['module']}/model/".$this->rName($this->options['model']['name']).".php"; + $fs->mkdir(__DIR__ . "/../../../{$this->options['model']['module']}/model"); + $this->deleteFileIfExists($modelPath); + $ModelStub = $this->generateFile($templateDir, 'ModelStub.stub', $modelPath, 'model'); + $resultStr .= $ModelStub; + } + + if (!empty($this->options['validate'])) { + $validatePath = __DIR__ . "/../../../{$this->options['validate']['module']}/validate/".$this->rName($this->options['validate']['name'])."Validate.php"; + $fs->mkdir(__DIR__ . "/../../../{$this->options['validate']['module']}/validate"); + $this->deleteFileIfExists($validatePath); + $ValidatorStub = $this->generateFile($templateDir, 'ValidateStub.stub', $validatePath, 'validate'); + $resultStr .= $ValidatorStub; + } + + return $resultStr ? $resultStr . "文件生成成功!
" : '未生成任何文件'; + } + + /** + * 根据模板生成文件 + * + * @param string $templateDir 模板目录 + * @param string $templateFile 模板文件名 + * @param string $outputPath 输出文件路径 + */ + protected function generateFile($templateDir, $templateFile, $outputPath, $fileType) + { + $templatePath = $templateDir . $templateFile; + + if (!file_exists($templatePath)) { + echo "模板文件 {$templateFile} 不存在! \n"; + return; + } + + $content = file_get_contents($templatePath); + + switch ($fileType) { + case 'controller': + $content = str_replace('{{controllerModule}}', $this->options['controller']['module'], $content); + $content = str_replace('{{serviceModule}}', $this->options['service']['module'], $content); + $content = str_replace('{{serviceName}}', $this->rName($this->options['service']['name']), $content); + $content = str_replace('{{controllerName}}', $this->rName($this->options['controller']['name']), $content); + break; + case 'service': + $content = str_replace('{{serviceModule}}', $this->options['service']['module'], $content); + $content = str_replace('{{validateModule}}', $this->options['validate']['module'], $content); + $content = str_replace('{{validateName}}', $this->rName($this->options['validate']['name']), $content); + $content = str_replace('{{modelModule}}', $this->options['model']['module'], $content); + $content = str_replace('{{modelName}}', $this->rName($this->options['model']['name']), $content); + $content = str_replace('{{serviceName}}', $this->rName($this->options['service']['name']), $content); + break; + case 'logic': + $content = str_replace('{{logicModule}}', $this->options['logic']['module'], $content); + $content = str_replace('{{modelModule}}', $this->options['model']['module'], $content); + $content = str_replace('{{modelName}}', $this->rName($this->options['model']['name']), $content); + $content = str_replace('{{logicName}}', $this->rName($this->options['logic']['name']), $content); + break; + case 'model': + $content = str_replace('{{modelModule}}', $this->options['model']['module'], $content); + $content = str_replace('{{modelName}}', $this->rName($this->options['model']['name']), $content); + $content = str_replace('{{table}}', $this->options['table'], $content); + $content = str_replace('{{fillable}}', $this->fillableFields(), $content); + break; + case 'validate': + $content = str_replace('{{validateModule}}', $this->options['validate']['module'], $content); + $content = str_replace('{{validateName}}', $this->rName($this->options['validate']['name']), $content); + break; + } + + file_put_contents($outputPath, $content); + + return "生成文件: {$outputPath}
"; + } + + /** + * 设置字段 + * + * @param array $fields 字段数组 + */ + public function fillableFields() + { + $tableName = $this->options['table']; + $fields = TableInspector::getColumnNames($tableName); + $str = ''; + foreach ($fields as $key => $val) { + if ($key < (count($fields)-1)) { + $str .= "'{$val}', "; + } else { + $str .= "'{$val}'"; + } + } + $fields = trim($str, '"'); + return $fields; + } + + /** + * 如果文件存在,删除它 + * + * @param string $path 文件路径 + */ + protected function deleteFileIfExists($path) + { + if (file_exists($path)) { + // "文件: {$path} 已存在,核对后手动删除。\n"; + // return; + + unlink($path); + echo "删除已存在文件: {$path} \n"; + } + } + + /** + * 设置名称 + */ + public function rName($name = '') + { + return ucfirst(camelize($name)); + } +} + +/** +* 下划线转驼峰 +* 思路: +* step1.原字符串转小写,原字符串中的分隔符用空格替换,在字符串开头加上分隔符 +* step2.将字符串中每个单词的首字母转换为大写,再去空格,去字符串首部附加的分隔符. +*/ +function camelize($uncamelized_words, $separator='_') { + $uncamelized_words = $separator . str_replace($separator, " ", strtolower($uncamelized_words)); + return ltrim(str_replace(" ", "", ucwords($uncamelized_words)), $separator ); +} + +/** +* 驼峰命名转下划线命名 +* 思路: +* 小写和大写紧挨一起的地方,加上分隔符,然后全部转小写 +*/ +function uncamelize($camelCaps, $separator='_') { + return strtolower(preg_replace('/([a-z])([A-Z])/', "$1" . $separator . "$2", $camelCaps)); +} \ No newline at end of file diff --git a/app/common/utils/scaffold/templates/ControllerStub.stub b/app/common/utils/scaffold/templates/ControllerStub.stub new file mode 100644 index 0000000..a74d97c --- /dev/null +++ b/app/common/utils/scaffold/templates/ControllerStub.stub @@ -0,0 +1,33 @@ +service = new {{serviceName}}Service(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/utils/scaffold/templates/Email.stub b/app/common/utils/scaffold/templates/Email.stub new file mode 100644 index 0000000..16fc580 --- /dev/null +++ b/app/common/utils/scaffold/templates/Email.stub @@ -0,0 +1,365 @@ + + + + + + 客户信息 - {{order_no}} + + + + + + + + + \ No newline at end of file diff --git a/app/common/utils/scaffold/templates/LogicStub.stub b/app/common/utils/scaffold/templates/LogicStub.stub new file mode 100644 index 0000000..da1afdc --- /dev/null +++ b/app/common/utils/scaffold/templates/LogicStub.stub @@ -0,0 +1,17 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/utils/scaffold/templates/ValidateStub.stub b/app/common/utils/scaffold/templates/ValidateStub.stub new file mode 100644 index 0000000..862b6f1 --- /dev/null +++ b/app/common/utils/scaffold/templates/ValidateStub.stub @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/utils/scaffold/templates/paymentFiles.stub b/app/common/utils/scaffold/templates/paymentFiles.stub new file mode 100644 index 0000000..6c9cded --- /dev/null +++ b/app/common/utils/scaffold/templates/paymentFiles.stub @@ -0,0 +1,103 @@ + + + + + + 邮箱提醒 + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/common/utils/scaffold/templates/workOrder.stub b/app/common/utils/scaffold/templates/workOrder.stub new file mode 100644 index 0000000..9f592dd --- /dev/null +++ b/app/common/utils/scaffold/templates/workOrder.stub @@ -0,0 +1,103 @@ + + + + + + 邮箱提醒 + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/common/utils/translation/config/trans.php b/app/common/utils/translation/config/trans.php new file mode 100644 index 0000000..83b9708 --- /dev/null +++ b/app/common/utils/translation/config/trans.php @@ -0,0 +1,34 @@ + [ + 'ticket_categorie' => [ + 'translation_model' => \app\ticket\model\CategorieTranslation::class, + 'foreign_key' => 'categorie_id', + ], + 'sys_currency' => [ + 'translation_model' => app\common\model\SysCurrencyTranslation::class, + 'foreign_key' => 'currency_id', + ], + 'sys_dict' => [ + 'translation_model' => app\common\model\SysDictTranslation::class, + 'foreign_key' => 'dict_id', + ], + 'sys_dict_item' => [ + 'translation_model' => app\common\model\SysDictItemTranslation::class, + 'foreign_key' => 'dict_item_id', + ], + 'sys_port_translation' => [ + 'translation_model' => app\common\model\SysPortTranslation::class, + 'foreign_key' => 'port_id', + ], + 'veh_color_translation' => [ + 'translation_model' => app\common\model\VehColorTranslation::class, + 'foreign_key' => 'color_id', + ], + ], + + // 默认支持的目标语种列表 + 'locales' => ['zh','en','ru','ar'], +]; diff --git a/app/common/utils/translation/jobs/TranslationJob.php b/app/common/utils/translation/jobs/TranslationJob.php new file mode 100644 index 0000000..ca584e2 --- /dev/null +++ b/app/common/utils/translation/jobs/TranslationJob.php @@ -0,0 +1,65 @@ +config = getConfig('/common/utils/translation/config/trans.php'); +// $this->config = getConfig('/queue/translation/config/trans.php'); + $this->payload = $payload; + } + + public function handle(): void + { + $cfgAll = $this->config['entities']; + $pt = $this->payload; + $type = $pt['entityType']; + $id = $pt['entityId']; + $locale = $pt['locale']; + $sourceLocale = $pt['sourceLocale']; + $sourceFields = $pt['sourceFields']; + $clientFields = $pt['clientFields']; + + if (! isset($cfgAll[$type])) { + Log::warning("TranslationJob: 未配置实体 {$type}"); + return; + } + + $cfg = $cfgAll[$type]; + $modelClass = $cfg['translation_model']; + $foreignKey = $cfg['foreign_key']; + + // 决定采用前端提供、源语种还是第三方翻译 + if (! empty($clientFields)) { + $fields = $clientFields; + } elseif ($locale === $sourceLocale) { + $fields = $sourceFields; + } else { + $translator = new TranslatorClient(); + $fields = []; + foreach ($sourceFields as $key => $text) { + $fields[$key] = $translator->translate($text, $sourceLocale, $locale); + } + } + + // 写库 + try { + $modelClass::updateOrCreate( + [ + $foreignKey => $id, + 'locale' => $locale, + ], + array_merge($fields, ['locale' => $locale]) + ); + } catch (\Throwable $e) { + Log::error("TranslationJob 保存失败: {$type}#{$id} locale={$locale}: ".$e->getMessage()); + } + } +} diff --git a/app/common/utils/translation/queue/Translation.php b/app/common/utils/translation/queue/Translation.php new file mode 100644 index 0000000..e9935f6 --- /dev/null +++ b/app/common/utils/translation/queue/Translation.php @@ -0,0 +1,50 @@ + 'tom@gmail.com', 'content' => 'hello'] + + $job = new TranslationJob($data); + $job->handle(); + } catch (\Throwable $e) { + Log::error("TranslationService 异步任务异常: ".$e->getMessage()); + } + } + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + } +} \ No newline at end of file diff --git a/app/common/utils/translation/service/TranslationService.php b/app/common/utils/translation/service/TranslationService.php new file mode 100644 index 0000000..1828ad4 --- /dev/null +++ b/app/common/utils/translation/service/TranslationService.php @@ -0,0 +1,43 @@ +config = getConfig('/common/utils/translation/config/trans.php'); +// $this->config = getConfig('/queue/translation/config/trans.php'); + } + + /** + * 异步排队所有语种的翻译任务 + * + * @param string $entityType 实体名,如 'vehicle' + * @param int $entityId 主表 ID + * @param string $sourceLocale 源语种,如 'zh' + * @param array $sourceFields 源语种字段 ['name'=>…,'condition_desc'=>…] + * @param array $clientFields 前端已传翻译 ['en'=>[...], …] + * @param array $intactFields 不需要翻译的字段 + */ + public function queueTranslations(string $entityType, int $entityId, string $sourceLocale, array $sourceFields, array $clientFields = [], array $intactFields = []): void + { + foreach ($this->config['locales'] as $locale) { + $payload = [ + 'entityType' => $entityType, + 'entityId' => $entityId, + 'locale' => $locale, + 'sourceLocale' => $sourceLocale, + 'sourceFields' => $sourceFields, + 'clientFields' => $clientFields[$locale] ?? [], + ]; + Redis::send('transTask', $payload); + } + } +} \ No newline at end of file diff --git a/app/common/utils/translation/service/TranslatorClient.php b/app/common/utils/translation/service/TranslatorClient.php new file mode 100644 index 0000000..ee227da --- /dev/null +++ b/app/common/utils/translation/service/TranslatorClient.php @@ -0,0 +1,38 @@ +endpoint = env('TRANSLATION_API_URL'); + // $this->apiKey = env('TRANSLATION_API_KEY'); + // } + + /** + * 翻译文本 + * @throws \RuntimeException + */ + public function translate(string $text, string $from = 'cn', string $to = 'ru'): string + { + try { + $xunfei = new XunfeiFanyi(); + $rst_dst = $xunfei->fanyi($text, $from, $to); + if ($rst_dst) { + return $rst_dst["trans_result"]["dst"]; + } else { + return ''; + } + } catch (\Throwable $e) { + Log::error("TranslatorClient 错误: ".$e->getMessage()); + throw new \RuntimeException('翻译服务调用失败'); + } + } +} diff --git a/app/common/validate/ApprovalFlowsValidate.php b/app/common/validate/ApprovalFlowsValidate.php new file mode 100644 index 0000000..4e4cb4d --- /dev/null +++ b/app/common/validate/ApprovalFlowsValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/ApprovalInstancesValidate.php b/app/common/validate/ApprovalInstancesValidate.php new file mode 100644 index 0000000..f34dbd9 --- /dev/null +++ b/app/common/validate/ApprovalInstancesValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/ApprovalRecordsValidate.php b/app/common/validate/ApprovalRecordsValidate.php new file mode 100644 index 0000000..648e0c6 --- /dev/null +++ b/app/common/validate/ApprovalRecordsValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/CorpFeishuAppValidate.php b/app/common/validate/CorpFeishuAppValidate.php new file mode 100644 index 0000000..d055d65 --- /dev/null +++ b/app/common/validate/CorpFeishuAppValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/CorpValidate.php b/app/common/validate/CorpValidate.php new file mode 100644 index 0000000..f1710d6 --- /dev/null +++ b/app/common/validate/CorpValidate.php @@ -0,0 +1,35 @@ + 'require|number', + 'name' => 'require|max:255', + 'abbr' => 'require|in:hesu,xinyouhe,paika,simate,mbaf', + 'tenant_key' => 'string', + 'sort' => 'number', + 'status' => 'require|in:0,1' + ]; + + protected $message = [ + 'id.require' => 'ID不能为空', + 'id.number' => 'ID必须是数字', + 'name.require' => '企业名称不能为空', + 'name.max' => '企业名称长度不能超过255个字符', + 'abbr.require' => '企业缩写不能为空', + 'abbr.in' => '企业缩写必须是 hesu,xinyouhe,paika,simate,mbaf 中的一个', + 'tenant_key.string' => '飞书企业标识必须是字符串', + 'sort.number' => '排序值必须是数字', + 'status.require' => '状态不能为空', + 'status.in' => '状态必须是0或1' + ]; + + protected $scene = [ + 'store' => ['name', 'abbr', 'tenant_key', 'sort', 'status'], + 'update' => ['id', 'name', 'abbr', 'tenant_key', 'sort', 'status'] + ]; +} \ No newline at end of file diff --git a/app/common/validate/CountryValidate.php b/app/common/validate/CountryValidate.php new file mode 100644 index 0000000..d2a413b --- /dev/null +++ b/app/common/validate/CountryValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/FinPaymentApplyValidate.php b/app/common/validate/FinPaymentApplyValidate.php new file mode 100644 index 0000000..58145e3 --- /dev/null +++ b/app/common/validate/FinPaymentApplyValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/FinReceiptHisValidate.php b/app/common/validate/FinReceiptHisValidate.php new file mode 100644 index 0000000..7a1f7c4 --- /dev/null +++ b/app/common/validate/FinReceiptHisValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/FinReceiptValidate.php b/app/common/validate/FinReceiptValidate.php new file mode 100644 index 0000000..237b03e --- /dev/null +++ b/app/common/validate/FinReceiptValidate.php @@ -0,0 +1,56 @@ + 'integer|gt:0', +// 'country_id' => 'require|integer|gt:0', +// 'remitter' => 'require', +// 'account_id' => 'require|integer|gt:0', +// 'currency_id' => 'require|integer|gt:0', +// 'amount' => 'require|gt:0', +// 'exchange_rate' => 'require|gt:0', +// 'arrived_at' => 'require', +// 'is_domestic_return' => 'require|integer', + ]; + + // 提示 + protected $message = [ +// 'payment_apply_id.integer' => '付款申请ID必须是整数', +// 'payment_apply_id.gt' => '付款申请ID必须大于0', +// 'country_id.require' => '付款来源国家ID不能为空', +// 'country_id.integer' => '付款来源国家ID必须是整数', +// 'country_id.gt' => '付款来源国家ID必须大于0', +// 'remitter.require' => '汇款人不能为空', +// 'account_id.require' => '收款账号ID不能为空', +// 'account_id.integer' => '收款账号ID必须是整数', +// 'account_id.gt' => '收款账号ID必须大于0', +// 'currency_id.require' => '收款币种ID不能为空', +// 'currency_id.integer' => '收款币种ID必须是整数', +// 'currency_id.gt' => '收款币种ID必须大于0', +// 'amount.require' => '收款金额不能为空', +// 'amount.gt' => '收款金额必须大于0', +// 'exchange_rate.require' => '实际汇率不能为空', +// 'exchange_rate.gt' => '实际汇率必须大于0', +// 'arrived_at.require' => '到账时间不能为空', +// 'arrived_at.gt' => '到账时间必须大于0', +// 'is_domestic_return.require' => '是否回转内地不能为空', +// 'is_domestic_return.integer' => '是否回转内地必须是整数', + ]; + + // 场景 + protected $scene = [ + 'store' => [ +// 'payment_apply_id', 'country_id', 'remitter', 'account_id', 'currency_id', 'amount', 'exchange_rate', 'arrived_at' + ], + + 'update' => [ +// 'payment_apply_id' + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/FinanceReconDubaiFilesValidate.php b/app/common/validate/FinanceReconDubaiFilesValidate.php new file mode 100644 index 0000000..193b230 --- /dev/null +++ b/app/common/validate/FinanceReconDubaiFilesValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/InquiryLogValidate.php b/app/common/validate/InquiryLogValidate.php new file mode 100644 index 0000000..e4cc026 --- /dev/null +++ b/app/common/validate/InquiryLogValidate.php @@ -0,0 +1,176 @@ + 'require|integer|gt:0', + 'order_no' => 'require|alphaDash|length:36', + 'enter_user_id'=> 'integer|egt:0', + 'recipient' => 'integer|egt:0', + 'quality' => 'require|in:1,2,3', + 'channel' => 'integer|between:0,99', + 'email' => 'email|max:255|checkEmailContactUnique', + 'tel' => 'max:255|checkTelContactUnique', + 'whatsapp' => 'max:255|checkWhatsappContactUnique', + 'country' => 'integer|egt:0|requireWith:contacts', + 'contacts' => 'require|max:255|requireWithout:email,tel,whatsapp|checkContactUnique', + 'content' => 'string', + 'status' => 'in:0,1,2', + 'r_time' => 'integer|egt:0', + 't_time' => 'integer|egt:0' + ]; + + // 提示 + protected $message = [ + 'id.require' => 'ID不能为空', + 'id.integer' => 'ID必须是整数', + 'id.gt' => 'ID必须大于0', + 'order_no.require' => '订单号不能为空', + 'order_no.alphaDash'=> '订单号只能包含字母、数字和下划线', + 'order_no.length' => '订单号长度必须为36位', + 'enter_user_id.integer' => '录入人ID必须是整数', + 'enter_user_id.egt' => '录入人ID不能为负数', + 'recipient.integer' => '接单人ID必须是整数', + 'recipient.egt' => '接单人ID不能为负数', + 'quality.require' => '询单等级不能为空', + 'quality.in' => '询单等级只能是1、2或3', + 'channel.integer' => '渠道编号必须是整数', + 'channel.between' => '渠道编号范围0-99', + 'email.email' => '邮箱格式不正确', + 'email.max' => '邮箱长度不能超过255个字符', + 'tel.max' => '电话长度不能超过255个字符', + 'whatsapp.max' => 'WhatsApp长度不能超过255个字符', + 'country.integer' => '国家代码必须是整数', + 'country.egt' => '国家代码不能为负数', + 'contacts.require' => '联系人不能为空', + 'contacts.max' => '联系人长度不能超过255个字符', + 'content.string' => '询单内容必须是字符串', + 'status.in' => '状态值只能是0、1或2', + 'r_time.integer' => '发布时间必须是整数', + 'r_time.egt' => '发布时间不能为负数', + 't_time.integer' => '跟踪时间必须是整数', + 't_time.egt' => '跟踪时间不能为负数', + 'email.checkEmailContactUnique' => '邮箱联系方式已存在', + 'tel.checkTelContactUnique' => '电话联系方式已存在', + 'whatsapp.checkWhatsappContactUnique' => 'WhatsApp联系方式已存在', + 'contacts.requireWithout' => '至少需要提供一种联系方式', + 'contacts.checkContactUnique' => '联系人已存在', + 'country.requireWith' => '国家必须与联系人一起提供' + ]; + + // 场景 + protected $scene = [ + 'store' => [ + 'enter_user_id', 'quality', 'channel', 'email', 'tel', 'whatsapp', 'country', 'contacts', 'content', 'recipient' + ], + 'update' => [ + 'quality', 'channel', 'email', 'tel', 'whatsapp', 'country', 'contacts', 'content', 'recipient' + ], + 'release' => [ + 'status', 'r_time' + ], + 'allocation' => [ + 'status', 't_time' + ] + ]; + + + /** + * 验证联系方式唯一性 + */ + protected function checkContactUnique($value, $rule, $data, $field, $title) + { + // 如果是更新操作且联系人未修改,跳过检查 + if ($this->currentScene == 'update') { + $original = InquiryLog::find($data['id']); + if ($original && $original->contacts == $value && $original->country == ($data['country'] ?? 0)) { + return true; + } + } + + if (empty($data['email']) && empty($data['tel']) && empty($data['whatsapp'])) { + $query = InquiryLog::where('contacts', trim($value)) + ->where('country', $data['country']); + + if (!empty($data['id'])) { + $query->where('id', '<>', $data['id']); + } + + return !$query->exists(); + } + + return true; + } + + /** + * 验证邮箱联系方式唯一性 + */ + protected function checkEmailContactUnique($value, $rule, $data, $field, $title) + { + if (empty($value)) return true; + + // 如果是更新操作且邮箱未修改,跳过检查 + if ($this->currentScene == 'update' && !empty($data['id'])) { + $original = InquiryLog::find($data['id']); + if ($original && $original->email == $value) { + return true; + } + } + + $query = InquiryLog::where('email', trim($value)); + if (!empty($data['id'])) { + $query->where('id', '<>', $data['id']); + } + return !$query->exists(); + } + + /** + * 验证电话联系方式唯一性 + */ + protected function checkTelContactUnique($value, $rule, $data, $field, $title) + { + if (empty($value)) return true; + + // 如果是更新操作且电话未修改,跳过检查 + if ($this->currentScene == 'update' && !empty($data['id'])) { + $original = InquiryLog::find($data['id']); + if ($original && $original->tel == $value) { + return true; + } + } + + $query = InquiryLog::where('tel', trim($value)); + if (!empty($data['id'])) { + $query->where('id', '<>', $data['id']); + } + return !$query->exists(); + } + + /** + * 验证WhatsApp联系方式唯一性 + */ + protected function checkWhatsappContactUnique($value, $rule, $data, $field, $title) + { + if (empty($value)) return true; + + // 如果是更新操作且WhatsApp未修改,跳过检查 + if ($this->currentScene == 'update' && !empty($data['id'])) { + $original = InquiryLog::find($data['id']); + if ($original && $original->whatsapp == $value) { + return true; + } + } + + $query = InquiryLog::where('whatsapp', trim($value)); + if (!empty($data['id'])) { + $query->where('id', '<>', $data['id']); + } + return !$query->exists(); + } +} \ No newline at end of file diff --git a/app/common/validate/InquiryTransmitConfigValidate.php b/app/common/validate/InquiryTransmitConfigValidate.php new file mode 100644 index 0000000..f80ec8d --- /dev/null +++ b/app/common/validate/InquiryTransmitConfigValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/InquiryTransmitValidate.php b/app/common/validate/InquiryTransmitValidate.php new file mode 100644 index 0000000..58c120a --- /dev/null +++ b/app/common/validate/InquiryTransmitValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/InvItemValidate.php b/app/common/validate/InvItemValidate.php new file mode 100644 index 0000000..14ebf6d --- /dev/null +++ b/app/common/validate/InvItemValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/InvRequisitionApproveValidate.php b/app/common/validate/InvRequisitionApproveValidate.php new file mode 100644 index 0000000..87df2e9 --- /dev/null +++ b/app/common/validate/InvRequisitionApproveValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/InvRequisitionAttachmentValidate.php b/app/common/validate/InvRequisitionAttachmentValidate.php new file mode 100644 index 0000000..b9f685f --- /dev/null +++ b/app/common/validate/InvRequisitionAttachmentValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/InvRequisitionItemReturnAttachmentValidate.php b/app/common/validate/InvRequisitionItemReturnAttachmentValidate.php new file mode 100644 index 0000000..26f3ee0 --- /dev/null +++ b/app/common/validate/InvRequisitionItemReturnAttachmentValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/InvRequisitionItemReturnValidate.php b/app/common/validate/InvRequisitionItemReturnValidate.php new file mode 100644 index 0000000..df63c90 --- /dev/null +++ b/app/common/validate/InvRequisitionItemReturnValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/InvRequisitionItemValidate.php b/app/common/validate/InvRequisitionItemValidate.php new file mode 100644 index 0000000..fed7231 --- /dev/null +++ b/app/common/validate/InvRequisitionItemValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/InvRequisitionValidate.php b/app/common/validate/InvRequisitionValidate.php new file mode 100644 index 0000000..584020d --- /dev/null +++ b/app/common/validate/InvRequisitionValidate.php @@ -0,0 +1,55 @@ + 'number', + 'borrow_reason' => 'require|max:255', + 'use_start_date' => 'require|date', + 'use_end_date' => 'date', + 'items' => 'require|array', + 'items.*.item_id' => 'require|number', + 'items.*.request_qty' => 'require|number|gt:0', + ]; + + // 提示 + protected $message = [ + 'applicant_id.number' => '申请人格式错误', + 'borrow_reason.require' => '领用事由不能为空', + 'borrow_reason.max' => '领用事由最多不能超过255个字符', + 'use_start_date.require' => '使用开始日期不能为空', + 'use_start_date.date' => '使用开始日期格式错误', + 'use_end_date.date' => '预计归还日期格式错误', + 'items.require' => '申请物品明细不能为空', + 'items.array' => '申请物品明细格式错误', + 'items.*.item_id.require' => '物品ID不能为空', + 'items.*.item_id.number' => '物品ID格式错误', + 'items.*.request_qty.require' => '申请数量不能为空', + 'items.*.request_qty.number' => '申请数量格式错误', + 'items.*.request_qty.gt' => '申请数量必须大于0', + ]; + + // 场景 + protected $scene = [ + 'store' => [ + + ], + + 'update' => [ + + ], + + 'apply' => [ + 'applicant_id', + 'borrow_reason', + 'use_start_date', + 'use_end_date', + 'items' + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/MessageNotificationsValidate.php b/app/common/validate/MessageNotificationsValidate.php new file mode 100644 index 0000000..c5dd6e1 --- /dev/null +++ b/app/common/validate/MessageNotificationsValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/MessageUserStatusValidate.php b/app/common/validate/MessageUserStatusValidate.php new file mode 100644 index 0000000..647cebd --- /dev/null +++ b/app/common/validate/MessageUserStatusValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/PurchaseOrderItemsValidate.php b/app/common/validate/PurchaseOrderItemsValidate.php new file mode 100644 index 0000000..e44a977 --- /dev/null +++ b/app/common/validate/PurchaseOrderItemsValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/PurchaseOrdersValidate.php b/app/common/validate/PurchaseOrdersValidate.php new file mode 100644 index 0000000..a8b4a5a --- /dev/null +++ b/app/common/validate/PurchaseOrdersValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/ResourceValidate.php b/app/common/validate/ResourceValidate.php new file mode 100644 index 0000000..ea2646e --- /dev/null +++ b/app/common/validate/ResourceValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/RoleValidate.php b/app/common/validate/RoleValidate.php new file mode 100644 index 0000000..dec4431 --- /dev/null +++ b/app/common/validate/RoleValidate.php @@ -0,0 +1,32 @@ + 'require|integer', + 'system_id' => 'require|integer', + 'name' => 'require|max:64', + 'desc' => 'max:128', + 'sort' => 'integer', + 'status' => 'in:0,1', + ]; + + protected $message = [ + 'id.require' => '角色ID不能为空', + 'system_id.require' => '系统ID不能为空', + 'name.require' => '角色名称不能为空', + 'name.max' => '角色名称不能超过64个字符', + 'desc.max' => '描述不能超过128个字符', + 'sort.integer' => '排序为数字', + 'status.in' => '状态值必须为0或1', + ]; + + protected $scene = [ + 'store' => ['name', 'desc', 'sort'], + 'update' => ['id', 'name', 'desc', 'sort', 'status'], + ]; +} diff --git a/app/common/validate/StockOrderAccountsValidate.php b/app/common/validate/StockOrderAccountsValidate.php new file mode 100644 index 0000000..8c63773 --- /dev/null +++ b/app/common/validate/StockOrderAccountsValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/StockOrderItemOptionsValidate.php b/app/common/validate/StockOrderItemOptionsValidate.php new file mode 100644 index 0000000..4f3c522 --- /dev/null +++ b/app/common/validate/StockOrderItemOptionsValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/StockOrderItemsValidate.php b/app/common/validate/StockOrderItemsValidate.php new file mode 100644 index 0000000..501e690 --- /dev/null +++ b/app/common/validate/StockOrderItemsValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/StockOrdersFilesValidate.php b/app/common/validate/StockOrdersFilesValidate.php new file mode 100644 index 0000000..c7fca82 --- /dev/null +++ b/app/common/validate/StockOrdersFilesValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/StockOrdersValidate.php b/app/common/validate/StockOrdersValidate.php new file mode 100644 index 0000000..884f3ae --- /dev/null +++ b/app/common/validate/StockOrdersValidate.php @@ -0,0 +1,209 @@ + 'number|egt:0', + 'order_no' => 'require|max:40|unique:stock_orders', + 'pi_no' => 'require|max:64', + 'biz_date' => 'require', + 'salesperson_id' => 'number|egt:0', + 'salesperson_snapshot' => 'max:64', + 'clearance_country_id' => 'require|number|egt:0', + 'clearance_country_snapshot' => 'require|max:64', + 'customer_source_id' => 'require|number|egt:0', + 'customer_source_snapshot' => 'require|max:64', + 'customer_id' => 'require|number|egt:0', + 'customer_snapshot' => 'require|max:128', + 'customer_country_id' => 'require|number|egt:0', + 'customer_country_snapshot' => 'require|max:64', + 'declarant_name' => 'require|max:64', + 'eta_ship_date' => 'number|egt:0', + 'shipping_method_id' => 'require|number', + 'shipping_method_snapshot' => 'require|max:32', + 'cif_total_amount' => 'require|float|egt:0', + 'domestic_port_id' => 'require|number|egt:0', + 'domestic_port_snapshot' => 'require|max:64', + 'trade_term_id' => 'require|number|egt:0', + 'trade_term_snapshot' => 'require|max:64', + 'clearance_port_id' => 'require|number|egt:0', + 'clearance_port_snapshot' => 'require|max:64', + 'latest_arrival_date' => 'number|egt:0', + 'contract_exchange_rate' => 'require|float|egt:0', + 'settlement_currency' => 'require|length:3|alpha', + 'remark' => 'max:255', + 'contract' => 'max:255', + 'is_use' => 'in:0,1', + 'object_id' => 'number|egt:0', + 'is_final' => 'in:0,1', + 'approval_status' => 'in:0,1,2,3', + 'status' => 'in:0,1', + 'file_url' => 'max:255', + 'pick_up_date' => 'number|egt:0', + 'collect_all_date' => 'number|egt:0', + 'created_at' => 'number|egt:0', + 'updated_at' => 'number|egt:0', + 'deleted_at' => 'number|egt:0', + ]; + + /** + * 错误提示信息 + */ + protected $message = [ + 'order_no.require' => '备货单编号不能为空', + 'order_no.max' => '备货单编号长度不能超过40个字符', + 'order_no.unique' => '备货单编号已存在', + 'pi_no.require' => 'PI号不能为空', + 'pi_no.max' => 'PI号长度不能超过64个字符', + 'biz_date.require' => '单据日期不能为空', + 'clearance_country_id.require' => '清关目的国ID不能为空', + 'clearance_country_snapshot.require' => '清关目的国名称不能为空', + 'customer_source_id.require' => '客户来源ID不能为空', + 'customer_source_snapshot.require' => '客户来源名称不能为空', + 'customer_id.require' => '客户ID不能为空', + 'customer_snapshot.require' => '客户名称不能为空', + 'customer_country_id.require' => '客户所在国ID不能为空', + 'customer_country_snapshot.require' => '客户所在国名称不能为空', + 'declarant_name.require' => '清关人不能为空', + 'shipping_method_id.require' => '出运方式ID不能为空', + 'shipping_method_snapshot.require' => '出运方式不能为空', + 'cif_total_amount.require' => '合计到岸价不能为空', + 'cif_total_amount.float' => '合计到岸价必须为数字', + 'cif_total_amount.egt' => '合计到岸价不能为负数', + 'domestic_port_id.require' => '国内收货港ID不能为空', + 'domestic_port_snapshot.require' => '国内收货港名称不能为空', + 'trade_term_id.require' => '贸易条款ID不能为空', + 'trade_term_snapshot.require' => '贸易条款名称不能为空', + 'clearance_port_id.require' => '清关目的港ID不能为空', + 'clearance_port_snapshot.require' => '清关目的港名称不能为空', + 'contract_exchange_rate.require' => '合同汇率不能为空', + 'contract_exchange_rate.float' => '合同汇率必须为数字', + 'contract_exchange_rate.egt' => '合同汇率不能为负数', + 'settlement_currency.require' => '结算币种不能为空', + 'settlement_currency.length' => '结算币种必须为3个字符', + 'settlement_currency.alpha' => '结算币种必须为字母', + 'approval_status.in' => '审批状态值不正确', + 'status.in' => '状态值不正确', + 'is_final.in' => '是否终版值不正确', + 'is_use.in' => '是否开过终版值不正确', + ]; + + /** + * 验证场景 + */ + protected $scene = [ + // 创建场景 + 'store' => [ + 'pi_no', 'biz_date', + 'clearance_country_id', 'clearance_country_snapshot', 'customer_source_id', + 'customer_source_snapshot', 'customer_id', 'customer_snapshot', + 'customer_country_id', 'customer_country_snapshot', 'declarant_name', + 'shipping_method_id', 'shipping_method_snapshot', + 'domestic_port_id', 'domestic_port_snapshot', + 'trade_term_id', 'trade_term_snapshot', 'clearance_port_id', + 'clearance_port_snapshot', 'contract_exchange_rate', + 'settlement_currency' + ], + + // 更新场景 + 'update' => [ + 'pi_no', 'biz_date', + 'clearance_country_id', 'clearance_country_snapshot', 'customer_source_id', + 'customer_source_snapshot', 'customer_id', 'customer_snapshot', + 'customer_country_id', 'customer_country_snapshot', 'declarant_name', + 'shipping_method_id', 'shipping_method_snapshot', + 'domestic_port_id', 'domestic_port_snapshot', + 'trade_term_id', 'trade_term_snapshot', 'clearance_port_id', + 'clearance_port_snapshot', 'contract_exchange_rate', + 'settlement_currency' + ], + + // 提交审批场景 + 'submit' => [ + 'id' => 'require|number|egt:1', + 'status' => 'require|in:1' + ], + + // 审批场景 + 'approve' => [ + 'id' => 'require|number|egt:1', + 'approval_status' => 'require|in:1,2,3' + ], + + // 状态更新场景 + 'status' => [ + 'id' => 'require|number|egt:1', + 'status' => 'require|in:0,1' + ], + + // 简略验证场景(用于快速验证) + 'simple' => [ + 'order_no', 'biz_date', 'cif_total_amount', 'settlement_currency' + ] + ]; + + /** + * 自定义验证规则 - 验证时间戳是否合理 + */ + protected function checkTimestamp($value, $rule, $data = []) + { + if ($value === 0 || $value === '0') { + return true; + } + + // 时间戳应该在1970年到现在+1年的范围内 + $minTimestamp = 0; + $maxTimestamp = time() + (365 * 24 * 60 * 60); + + return $value >= $minTimestamp && $value <= $maxTimestamp; + } + + /** + * 自定义验证规则 - 验证金额格式 + */ + protected function checkMoney($value, $rule, $data = []) + { + if (!is_numeric($value)) { + return false; + } + + // 验证小数点位数不超过2位 + if (strpos($value, '.') !== false) { + $decimalPart = explode('.', $value)[1]; + if (strlen($decimalPart) > 2) { + return false; + } + } + + return true; + } + + /** + * 场景验证前回调 - 添加自定义规则 + */ + // protected function sceneBefore($scene) + // { + // // 为时间戳字段添加自定义验证 + // $timestampFields = ['biz_date', 'eta_ship_date', 'latest_arrival_date', 'pick_up_date', 'collect_all_date', 'created_at', 'updated_at', 'deleted_at']; + // foreach ($timestampFields as $field) { + // if (isset($this->scene[$scene][$field])) { + // $this->rule[$field] .= '|checkTimestamp'; + // } + // } + + // // 为金额字段添加自定义验证 + // $moneyFields = ['cif_total_amount', 'contract_exchange_rate']; + // foreach ($moneyFields as $field) { + // if (isset($this->scene[$scene][$field])) { + // $this->rule[$field] .= '|checkMoney'; + // } + // } + // } +} \ No newline at end of file diff --git a/app/common/validate/SysOpLogValidate.php b/app/common/validate/SysOpLogValidate.php new file mode 100644 index 0000000..3d8a1d2 --- /dev/null +++ b/app/common/validate/SysOpLogValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/SysUpdateLogValidate.php b/app/common/validate/SysUpdateLogValidate.php new file mode 100644 index 0000000..59bb004 --- /dev/null +++ b/app/common/validate/SysUpdateLogValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/SystemsValidate.php b/app/common/validate/SystemsValidate.php new file mode 100644 index 0000000..d0783f5 --- /dev/null +++ b/app/common/validate/SystemsValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/UploadedFilesValidate.php b/app/common/validate/UploadedFilesValidate.php new file mode 100644 index 0000000..339ff2b --- /dev/null +++ b/app/common/validate/UploadedFilesValidate.php @@ -0,0 +1,82 @@ + 'require|max:255', + 'file_path' => 'require|max:500', + 'file_size' => 'require|integer|gt:0', + 'file_type' => 'require|max:100', + 'category' => 'require|max:50', + 'original_name' => 'max:255', + 'extension' => 'max:20', + 'partition' => 'max:50', + 'upload_ip' => 'ip', + 'user_agent' => 'max:500', + ]; + + /** + * 验证消息 + */ + protected $message = [ + 'file_name.require' => '文件名不能为空', + 'file_name.max' => '文件名长度不能超过255个字符', + 'file_path.require' => '文件路径不能为空', + 'file_path.max' => '文件路径长度不能超过500个字符', + 'file_size.require' => '文件大小不能为空', + 'file_size.integer' => '文件大小必须是整数', + 'file_size.gt' => '文件大小必须大于0', + 'file_type.require' => '文件类型不能为空', + 'file_type.max' => '文件类型长度不能超过100个字符', + 'category.require' => '文件分类不能为空', + 'category.max' => '文件分类长度不能超过50个字符', + 'original_name.max' => '原始文件名长度不能超过255个字符', + 'extension.max' => '文件扩展名长度不能超过20个字符', + 'partition.max' => '分区名称长度不能超过50个字符', + 'upload_ip.ip' => '上传IP格式不正确', + 'user_agent.max' => '用户代理长度不能超过500个字符', + ]; + + /** + * 验证场景 + */ + protected $scene = [ + 'create' => ['file_name', 'file_path', 'file_size', 'file_type', 'category'], + 'update' => ['file_name', 'file_path', 'file_size', 'file_type', 'category', 'original_name', 'extension', 'partition'], + 'search' => ['category', 'partition', 'file_type'], + ]; + + /** + * 验证文件上传 + */ + public function sceneUpload() + { + return $this->append('file_name', 'require|max:255') + ->append('file_path', 'require|max:500') + ->append('file_size', 'require|integer|gt:0') + ->append('file_type', 'require|max:100') + ->append('category', 'require|max:50') + ->append('original_name', 'max:255') + ->append('extension', 'max:20') + ->append('partition', 'max:50') + ->append('upload_ip', 'ip') + ->append('user_agent', 'max:500'); + } + + /** + * 验证文件信息更新 + */ + public function sceneUpdateInfo() + { + return $this->append('file_name', 'require|max:255') + ->append('category', 'require|max:50') + ->append('original_name', 'max:255'); + } +} \ No newline at end of file diff --git a/app/common/validate/UsedcarCrawlResultsImagesValidate.php b/app/common/validate/UsedcarCrawlResultsImagesValidate.php new file mode 100644 index 0000000..e6cbbd5 --- /dev/null +++ b/app/common/validate/UsedcarCrawlResultsImagesValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/UsedcarCrawlResultsValidate.php b/app/common/validate/UsedcarCrawlResultsValidate.php new file mode 100644 index 0000000..f0766bd --- /dev/null +++ b/app/common/validate/UsedcarCrawlResultsValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/UserGroupMembersValidate.php b/app/common/validate/UserGroupMembersValidate.php new file mode 100644 index 0000000..3a51e50 --- /dev/null +++ b/app/common/validate/UserGroupMembersValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/UserGroupValidate.php b/app/common/validate/UserGroupValidate.php new file mode 100644 index 0000000..2612b52 --- /dev/null +++ b/app/common/validate/UserGroupValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/UserValidate.php b/app/common/validate/UserValidate.php new file mode 100644 index 0000000..dcdaa9b --- /dev/null +++ b/app/common/validate/UserValidate.php @@ -0,0 +1,150 @@ + 'chsDash|max:60', + 'avatar' => 'regex:/^(https?:\/\/\S+|\\/[\S\/]+)$/', + 'password' => 'require|min:8|max:20|alphaNum', + 'confirm_password' => 'require|confirm:password', + 'account' => 'require|checkAccount|checkAccountUnique', + 'phone' => 'checkMobile|checkPhoneUnique', + 'email' => 'email|checkEmailUnique', + 'login_type' => 'require|in:upwd,feishu,qrcode', + 'system_code' => 'require|checkSystemCode', + 'corp_abbr' => 'require|checkCorpAbbr', + 'feishu_code' => 'require|string' + ]; + + protected $message = [ + 'name.chsDash' => '姓名只能是汉字、字母、数字和下划线_及破折号-', + 'name.max' => '姓名最多不能超过60个字符', + 'avatar.regex' => '头像必须是合法的URL或有效的文件路径', + 'password.require' => '密码不能为空', + 'password.min' => '密码长度不能少于8位', + 'password.max' => '密码长度不能超过20位', + 'password.alphaNum' => '密码只能包含字母和数字', + 'confirm_password.require' => '请再次输入密码', + 'confirm_password.confirm' => '两次输入的密码不一致', + 'account.require' => '账号不能为空', + 'account.checkAccount' => '账号必须是有效的手机号或邮箱', + 'account.checkAccountUnique' => '账号已被注册', + 'phone.checkMobile' => '手机号格式不正确', + 'phone.checkPhoneUnique' => '手机号已被使用', + 'email.email' => '邮箱格式不正确', + 'email.checkEmailUnique' => '邮箱已被使用', + 'login_type.require' => '请选择登录方式', + 'login_type.in' => '登录方式不支持', + 'system_code.require' => '系统码不能为空', + 'system_code.checkSystemCode' => '系统码不存在或不合法', + 'corp_abbr.require' => '公司标识不能为空', + 'corp_abbr.checkCorpAbbr' => '公司标识不存在或不合法', + 'feishu_code.require' => '飞书code不能为空', + 'feishu_code.string' => '飞书code必须是字符串' + ]; + + protected $scene = [ + 'upwd_register' => ['name', 'account', 'password', 'confirm_password'], + 'feishu_register' => ['feishu_code'], + 'upwd_login' => ['account' => 'require|checkAccount', 'password'], + 'feishu_auth' => ['corp_abbr', 'system_code', 'login_type'], + 'feishu_login' => ['feishu_code'], + ]; + + /** + * 验证账号格式(手机号或邮箱) + */ + protected function checkAccount($value) + { + return $this->isMobile($value) || $this->isEmail($value); + } + + /** + * 验证账号唯一性(注册场景用) + */ + protected function checkAccountUnique($value, $rule, $data, $field, $title) + { + // 登录场景不检查唯一性 + if (isset($this->currentScene) && $this->currentScene === 'login') { + return true; + } + + $type = $this->getAccountType($value); + $field = ($type === 'phone') ? 'phone' : 'email'; + $value = ($type === 'phone') ? $this->formatPhone($value) : $value; + + return !User::where($field, $value)->exists(); + } + + /** + * 判断是否为手机号 + */ + protected function isMobile($value) + { + $mobile = preg_replace('/[\s+\-]/', '', $value); + return preg_match('/^(\+86|0086)?1[3-9]\d{9}$/', $mobile); + } + + /** + * 判断是否为邮箱 + */ + protected function isEmail($value) + { + return filter_var($value, FILTER_VALIDATE_EMAIL); + } + + /** + * 获取账号类型(phone/email) + */ + public function getAccountType($account) + { + return $this->isMobile($account) ? 'phone' : 'email'; + } + + /** + * 格式化手机号(移除+86和空格) + */ + public function formatPhone($phone) + { + return preg_replace('/^(\+86|0086)|[\s\-]/', '', $phone); + } + + /** + * 手机号唯一性验证 + */ + protected function checkPhoneUnique($value) + { + return !User::where('phone', $this->formatPhone($value))->exists(); + } + + /** + * 邮箱唯一性验证 + */ + protected function checkEmailUnique($value) + { + return !User::where('email', $value)->exists(); + } + + /** + * 验证 system_code 是否在系统表中存在 + */ + protected function checkSystemCode($value, $rule, $data, $field) + { + return Systems::where('code', $value)->exists(); + } + + /** + * 验证 corp_abbr 企业标识是否存在 + */ + protected function checkCorpAbbr($value, $rule, $data, $field) + { + return Corp::where('abbr', $value)->exists(); + } +} \ No newline at end of file diff --git a/app/common/validate/VehDictI18nValidate.php b/app/common/validate/VehDictI18nValidate.php new file mode 100644 index 0000000..cb65500 --- /dev/null +++ b/app/common/validate/VehDictI18nValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/VehDictMappingValidate.php b/app/common/validate/VehDictMappingValidate.php new file mode 100644 index 0000000..1b4bc7a --- /dev/null +++ b/app/common/validate/VehDictMappingValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/VehMoneyValueValidate.php b/app/common/validate/VehMoneyValueValidate.php new file mode 100644 index 0000000..185ffa4 --- /dev/null +++ b/app/common/validate/VehMoneyValueValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/VehVehicleListingValidate.php b/app/common/validate/VehVehicleListingValidate.php new file mode 100644 index 0000000..ffa9c83 --- /dev/null +++ b/app/common/validate/VehVehicleListingValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/VehVehicleSkuValidate.php b/app/common/validate/VehVehicleSkuValidate.php new file mode 100644 index 0000000..7305e71 --- /dev/null +++ b/app/common/validate/VehVehicleSkuValidate.php @@ -0,0 +1,128 @@ + 'max:40|alphaNum', + 'fingerprint' => 'length:64|alphaNum', + 'brand_code' => 'require|max:32', + 'brand_name' => 'require|max:64', + 'series_code' => 'max:64', + 'series_name' => 'max:128', + 'model_code' => 'max:64', + 'model_name' => 'require|max:128', + 'model_year' => 'require|number|between:1900,2100', + 'trim_code' => 'max:64', + 'trim_level_code' => 'max:64', + 'edition_code' => 'max:64', + 'trim' => 'max:128', + 'trim_level' => 'max:64', + 'edition_name' => 'max:64', + 'body_type_code' => 'max:32', + 'drive_type_code' => 'max:16', + 'motor_layout_code' => 'max:32', + 'fuel_type_code' => 'max:16', + 'engine_disp_l' => 'float|between:0,99.9', + 'engine_turbo' => 'in:0,1', + 'engine_power_kw' => 'number|between:0,2000', + 'battery_kwh' => 'float|between:0,999.99', + 'transmission_code' => 'max:16', + 'gears' => 'number|between:1,20', + 'range_km' => 'number|between:0,2000', + 'range_std_code' => 'max:16', + 'doors' => 'number|between:2,10', + 'seats' => 'number|between:1,20', + 'weight_kg' => 'number|between:0,10000', + 'dimensions_mm' => 'array', + 'color_exterior_code'=> 'max:32', + 'color_exterior_text'=> 'max:64', + 'color_interior_code'=> 'max:32', + 'color_interior_text'=> 'max:64', + 'options_official' => 'array', + 'features' => 'array', + 'emission_std_code' => 'max:16', + 'origin_country' => 'alpha|length:2', + 'currency' => 'require|alpha|length:3', + 'msrp' => 'float|between:0,9999999999.99', + 'is_active' => 'in:0,1', + 'corp_id' => 'require|number', + 'remark' => 'max:255', + 'created_by' => 'number', + 'updated_by' => 'number', + 'created_at' => 'require|number', + 'updated_at' => 'require|number', + ]; + + /** + * 字段别名 + */ + protected $field = [ + 'sku_code' => 'SKU编码', + 'fingerprint' => '规格指纹', + 'brand_code' => '品牌代码', + 'brand_name' => '品牌名称', + 'series_code' => '车系代码', + 'series_name' => '车系名称', + 'model_code' => '车型代码', + 'model_name' => '车型名称', + 'model_year' => '年款', + 'trim_code' => '版型代码', + 'trim_level_code' => '配置等级代码', + 'edition_code' => '特别版代码', + 'trim' => '版型/细分配置', + 'trim_level' => '配置等级', + 'edition_name' => '特别版名称', + 'body_type_code' => '车身类型', + 'drive_type_code' => '驱动方式', + 'motor_layout_code' => '电机布置', + 'fuel_type_code' => '能源类型', + 'engine_disp_l' => '排量(L)', + 'engine_turbo' => '是否涡轮', + 'engine_power_kw' => '最大功率(kW)', + 'battery_kwh' => '电池容量(kWh)', + 'transmission_code' => '变速箱代码', + 'gears' => '挡位数', + 'range_km' => '续航里程', + 'range_std_code' => '续航标准', + 'doors' => '门数', + 'seats' => '座位数', + 'weight_kg' => '整备质量', + 'dimensions_mm' => '车身尺寸', + 'color_exterior_code'=> '外观色代码', + 'color_exterior_text'=> '外观色名称', + 'color_interior_code'=> '内饰色代码', + 'color_interior_text'=> '内饰色名称', + 'options_official' => '官方选装', + 'features' => '功能配置', + 'emission_std_code' => '排放标准', + 'origin_country' => '原产国', + 'currency' => '币种', + 'msrp' => '建议零售价', + 'is_active' => '是否启用', + 'corp_id' => '公司ID', + 'remark' => '备注', + 'created_by' => '创建人', + 'updated_by' => '更新人', + 'created_at' => '创建时间', + 'updated_at' => '更新时间', + ]; + + /** + * 场景定义 + */ + protected $scene = [ + 'store' => [ + 'brand_code', 'brand_name', 'model_year','currency' + ], + 'update' => [ + 'id', 'brand_code','brand_name', 'model_year','currency' + ], + ]; +} \ No newline at end of file diff --git a/app/common/validate/VehVehicleUnitValidate.php b/app/common/validate/VehVehicleUnitValidate.php new file mode 100644 index 0000000..11f91a9 --- /dev/null +++ b/app/common/validate/VehVehicleUnitValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/controller/IndexController.php b/app/controller/IndexController.php new file mode 100644 index 0000000..08922ab --- /dev/null +++ b/app/controller/IndexController.php @@ -0,0 +1,174 @@ + 'webman']); + } + + /** + * 临时增加用户权限 + */ + public function perm(Request $request) + { + try { + $data = $request->get(); + + if (Db::table('user_roles')->where('user_id', $data['user_id'])->first()) { + Db::table('user_roles')->where('user_id', $data['user_id'])->update([ + 'role_id' => $data['role_id'] + ]); + } else { + Db::table('user_roles')->insert([ + 'user_id' => $data['user_id'], + 'role_id' => $data['role_id'] + ]); + } + + if (!Db::table('user_systems')->where('user_id', $data['user_id'])->where('system_id', $data['system_id'])->first()) { + Db::table('user_systems')->where('user_id', $data['user_id'])->insert([ + 'user_id' => $data['user_id'], + 'system_id' => $data['system_id'], + 'created_at' => time() + ]); + } + + Cache::delete("user_resources_{$data['user_id']}"); + return json(['code' => 0, 'msg' => 'success']); + } catch (\Throwable $e) { + return json(['code' => 1, 'msg' => $e->getMessage()]); + } + } + + /** + * 生成资源 + */ + public function createResource() + { + $options = [ + 'controller' => [ + 'module' => 'common', + 'name' => 'inv_requisition_item_return_attachment' + ], + 'model' => [ + 'module' => 'common', + 'name' => 'inv_requisition_item_return_attachment' + ], + 'service' => [ + 'module' => 'common', + 'name' => 'inv_requisition_item_return_attachment' + ], + 'logic' => [ + 'module' => 'common', + 'name' => 'inv_requisition_item_return_attachment' + ], + 'validate' => [ + 'module' => 'common', + 'name' => 'inv_requisition_item_return_attachment' + ], + 'table' => 'inv_requisition_item_return_attachment' + ]; + + $result = (new Generate($options))->doGenerate(); + return $result; + } + + /** + * 菜单迁移 + */ + public function doMenu() + { + // 菜单迁移 (部分) + $where[] = ['id', '>', 239]; + (new \app\common\utils\MigrationResources())->do('resources', 'resources_20251012_bak', $where); + + // 整体 + // $list = (new \app\common\utils\MigrationResources())->getFullResourceTreeOptimized('resources_20250916_bak', []); + // var_dump($list); + } + + + public function pdf(Request $request) + { + $id = $request->get('id'); + $service = new \app\common\service\StockOrdersService(); + $service->createdPdfFile($id); + return json(['code' => 0, 'msg' => 'success']); + } + + + public function tocurl($url, $header = null, $content = null) + { + $ch = curl_init(); + if(substr($url,0,5)=='https'){ + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 跳过证书检查 + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); // 从证书中检查SSL加密算法是否存在 + curl_setopt($ch, CURLOPT_SSLVERSION, 1); + } + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_URL, $url); + if (is_array($header)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $header); + } + curl_setopt($ch, CURLOPT_POST, true); + if (!empty($content)) { + if (is_array($content)) { + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($content)); + } else if (is_string($content)) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $content); + } + } + $response = curl_exec($ch); + $error=curl_error($ch); + if($error){ + die($error); + } + $header = curl_getinfo($ch); + curl_close($ch); + $data = array('header' => $header,'body' => $response); + return $data; + } + + + + public function test(Request $request) + { + $service = new \app\common\utils\crawl\service\UsedCarCrawl(); + + $result = $service->pushUrl('https://www.dongchedi.com/usedcar/21631642'); + + // $result = $service->doData(); + + // $result = $service->decodeDongchediPrice('.'); + + // $result = $service->detectEnergyType('油电混合'); + + return json($result); + } +} \ No newline at end of file diff --git a/app/crmAdmin/controller/BaseController.php b/app/crmAdmin/controller/BaseController.php new file mode 100644 index 0000000..1c066b5 --- /dev/null +++ b/app/crmAdmin/controller/BaseController.php @@ -0,0 +1,51 @@ +scene($scene); + } + // 执行数据验证 + if (!$validator->check($data)) { + // 验证失败,抛出业务异常,异常信息为验证器返回的错误信息 + throw new BusinessException($validator->getError()); + } + // 验证成功,返回原始数据 + return $data; + } + + public function page($result){ + $current = $result->currentPage(); + $total = $result->total(); + $size = $result->perPage(); + $result = $result->toArray(); + $result['current'] = $current; + $result['total'] = $total; + $result['size'] = $size; + return $result; + } +} \ No newline at end of file diff --git a/app/crmAdmin/controller/CustomerController.php b/app/crmAdmin/controller/CustomerController.php new file mode 100644 index 0000000..161c4f7 --- /dev/null +++ b/app/crmAdmin/controller/CustomerController.php @@ -0,0 +1,270 @@ +all(); + $corp = Corp::where(['abbr' => $request->basic_data['corp_abbr']])->first(); + $param['corp_id'] = $corp->id; +// $param['corp_abbr'] = $request->basic_data['corp_abbr']; + $result = (new CustomerService())->list($param); + + return $this->success($this->page($result)); + } catch (\Throwable $e) { + return $this->error($e->getMessage().$e->getFile().$e->getLine().$e->getTraceAsString()); + } + } + + /** + * 客户导出 + * @param Request $request + * @return \support\Response + */ + public function export(Request $request) + { + try { + $param = $request->all(); +// $corp = Corp::where(['abbr' => $request->basic_data['corp_abbr']])->first(); +// $param['corp_id'] = $corp->id; + + $result = (new CustomerService())->export($param); + $excelContent = file_get_contents($result['filepath']); + // 生成文件名 + $filename = '客户数据_' . date('Y-m-d_H-i-s') . '.xlsx'; + return response() + ->withHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + ->withHeader('Content-Disposition', 'attachment; filename="'.$filename.'"') + ->withBody($excelContent); + + /*$result = (new CustomerService())->export($param); + // 检查文件是否存在 + if (!file_exists($result['filepath'])) { + return $this->error('导出文件生成失败'); + } + + // 获取文件信息 + $fileSize = filesize($result['filepath']); + $filename = $result['filename']; + + // 设置响应头 + $response = response()->file($result['filepath']); + $response->header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $response->header('Content-Disposition', 'attachment; filename="' . $filename . '"'); + $response->header('Content-Length', $fileSize); + $response->header('Cache-Control', 'no-cache, must-revalidate'); + $response->header('Pragma', 'no-cache'); + + return $response;*/ + + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 审核客户 + * @param Request $request + * @return \support\Response + */ + public function audit(Request $request) + { + try { + $param = $request->all(); + if (empty($param['id'])) { + return $this->error('ID'.trans('not_null')); + } + + if (!in_array($param['scene'],['delete','report'])) { + return $this->error('scene 不合法'); + } + $result = (new CustomerService())->audit($param); + + return $this->success($result); + + } catch (\Throwable $e) { + return $this->error($e->getMessage().$e->getFile().$e->getLine().$e->getTraceAsString()); + } + } + + + /** + * 批量转让 + * @param Request $request + * @return \support\Response + */ + public function transfer(Request $request) + { + try { + $param = $request->all(); + if (empty($param['customer_id'])) { + return $this->error('customer_id'.trans('not_null')); + } + if (empty($param['user_id'])) { + return $this->error('user_id'.trans('not_null')); + } + + $result = (new CustomerService())->transfer($param); + + return $this->success($result); + } catch (\Throwable $th) { + return $this->error($th->getMessage()); + } + } + + /** + * 移除 + * @param Request $request + * @return \support\Response + */ + public function delete(Request $request) + { + try { + $param = $request->all(); + $scene = $param['scene'] ?? ''; + $status_desc = $param['status_desc'] ?? ''; + + if($scene != 'force' && empty($param['status_desc'])){ + return $this->error( '移除原因不能为空'); + } + if (empty($param['customer_id'])) { + return $this->error('customer_id'.trans('not_null')); + } + + $customer_id = explode(',', $param['customer_id']); + $query = Customer::whereIn('id',$customer_id)->withTrashed(); + if(!$query->exists()){ + return $this->error( trans('fail')); + } + if ($scene === 'list') { //管理端客户列表移除 + $res = $query->update(['status' => 4, 'status_desc' => $status_desc, 'deleted_at' => time()]); + } elseif ($scene === 'force') { //强删除 + $res = $query->forceDelete(); + } else { //公海软删除 + $res = $query->update(['status' => 3, 'status_desc' => $status_desc, 'deleted_at' => time()]); + } + + return $this->success($res); + } catch (\Throwable $th) { + return $this->error($th->getMessage()); + } + } + + + /** + * 客户恢复 + * @param Request $request + * @return \support\Response + */ + public function restore(Request $request) + { + try { + $id = $request->input('id', ''); + if (Util::isEmpty($id)) { + return $this->error(trans('id_empty')); + } + $ids = explode(',', $id); + $query = Customer::withTrashed()->whereIn('id', $ids); + if (!$query->exists()) { + return $this->error(trans('fail')); + } + // 恢复软删除(清空 deleted_at) + $query->restore(); + // 恢复为在库状态,放入公海 + $res = Customer::whereIn('id', $ids)->update(['status' => 1, 'user_id' => 0]); + return $this->success($res, trans('success')); + } catch (\Throwable $th) { + return $this->error($th->getMessage()); + } + } + + /** + * 客户详情 + * @param Request $request + * @return \support\Response + */ + public function detail(Request $request) + { + try { + $param = $request->all(); + if (empty($param['id'])) { + return $this->error('id'.trans('not_null')); + } + + $result = (new CustomerService())->detail($param); + + return $this->success($result); + } catch (\Throwable $th) { + return $this->error($th->getMessage()); + } + } + + /** + * 客户详情 + * @param Request $request + * @return \support\Response + */ + public function follow(Request $request) + { + try { + $param = $request->all(); + if (empty($param['id'])) { + return $this->error('id'.trans('not_null')); + } + + $result = (new CustomerService())->follow($param); + + return $this->success($result); + } catch (\Throwable $th) { + return $this->error($th->getMessage()); + } + } + + /** + * 离职员工客户分配 + * @param Request $request + * @return \support\Response + */ + public function allocation(Request $request) + { + try { + $param = $request->all(); + if (empty($param['corp_id'])) { + return $this->error('corp_id'.trans('not_null')); + } + if (empty($param['user_id'])) { + return $this->error('user_id'.trans('not_null')); + } + + $result = (new CustomerService())->allocation($param); + + return $this->success($result,'成功分配'.$result['userCount'].'位离职用户的'.$result['customerCount'].'位客户'); + } catch (\Throwable $th) { + return $this->error($th->getMessage()); + } + } + +} \ No newline at end of file diff --git a/app/crmAdmin/controller/StatisticsController.php b/app/crmAdmin/controller/StatisticsController.php new file mode 100644 index 0000000..2d1e47f --- /dev/null +++ b/app/crmAdmin/controller/StatisticsController.php @@ -0,0 +1,187 @@ +service = new CountryService(); + } + + public function whole() + { + $totals = Customer::count('id'); + $channelCount = InquiryChannel::count('id'); + $todayCount = Customer::where('created_at', '>=', strtotime(date('Y-m-d')))->count('id'); + $releaseCount = Customer::distinct('enter_user_id')->count('id'); + $countryCount = Country::count('id'); + $trackCount = Customer::distinct('user_id')->count(); + + $data = [ + 'totals' => $totals, + 'channelCount' => $channelCount, + 'todayCount' => $todayCount, + 'releaseCount' => $releaseCount, + 'countryCount' => $countryCount, + 'trackCount' => $trackCount + ]; + return $this->success($data); + } + + // 各渠道计数 + public function channelCount() + { + $inquiryChannelList = InquiryChannel::all(['id', 'name']); + $channels = []; + foreach ($inquiryChannelList as $key => $val) { + $channels[$val->id] = $val->name; + } + + $list = Customer::select('channel_id', Db::raw('COUNT(*) as totals')) + ->groupBy('channel_id') + ->get()->toArray(); + + $channelCounts = []; + foreach ($list as $vo) { + $channelCounts[] = [ + "name" => $channels[$vo["channel_id"]], + "value" => $vo["totals"] + ]; + } + return $this->success($channelCounts); + } + + // 各国家计数 + public function countryCount() + { + $list = Customer::with("countryInfo") + ->select('country_id', Db::raw('COUNT(*) as totals')) + ->where('country_id','<>',0) + ->groupBy('country_id') + ->get(); + $arr = []; + foreach ($list as $val) { + $arr[] = [ + "value" => $val->totals, + "name" => $val->countryInfo->cn_addr + ]; + } + return $this->success($arr); + } + + // 跟单人计数 + public function trackCount() + { + $list = Customer::with(["user"]) + ->where("user_id", ">", 0) + ->whereNotIn('user_id', [1,5,10,11,13,155,16,17,19,74,75,78,141,147]) + ->select('user_id', Db::raw('COUNT(*) as totals')) + ->groupBy('user_id') + ->get(); + $optionType = []; + $counts = []; + foreach ($list as $val) { + if (isset($val->user) && $val->user) { + $optionType[] = $val->user->name; + $counts[] = $val->totals; + } + } + $arr = [ + "optionType" => $optionType, + "counts" => $counts + ]; + return $this->success($arr); + } + + // 每日录单数 + public function dayCount() + { + $list = Db::table('crm_customer') + ->where('created_at', '>=', Carbon::now()->subDay(7)->timestamp) + ->select( + Db::raw('DATE(FROM_UNIXTIME(created_at)) as date'), + Db::raw('COUNT(*) as order_count') + ) + ->groupBy('date') + ->pluck('order_count', 'date')->toArray(); + $optionType = []; + $counts = []; + foreach ($list as $key=>$val) { + $optionType[] = $key; + $counts[] = $val; + } + $arr = [ + "optionType" => $optionType, + "counts" => $counts + ]; + return $this->success($arr); + } + + public function customerCount(Request $request) + { + $companyId = $request->get('company_id') ?? 0; + + // 1. 计算时间范围 + $now = Carbon::now(); + $sixMonthsAgo = $now->copy()->subMonths(5)->startOfMonth(); // 包含6个月 + + // 2. 获取关联渠道ID + $channelIds = $this->getChannelIdsByCompany($companyId); + + if (empty($channelIds)) { + return collect(); + } + + // 3. 执行查询 + $list = Customer::select([ + DB::raw("DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m') AS month"), + DB::raw('COUNT(*) AS total_count') + ]) + ->whereIn('channel_id', $channelIds) + ->where('created_at', '>', 0) + ->where('created_at', '>=', $sixMonthsAgo->timestamp) // 添加时间限制 + ->groupBy('month') + ->orderBy('month') + ->get(); + + $optionType = []; + $counts = []; + foreach ($list as $item) { + $optionType[] = $item->month; + $counts[] = $item->total_count; + } + $arr = [ + "optionType" => $optionType, + "counts" => $counts + ]; + return $this->success($arr); + } + + /** + * 获取公司与渠道的映射 + */ + private function getChannelIdsByCompany($companyId) + { + $data = [ + 0 => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + 1 => [1, 2, 3, 4, 5, 6, 7, 8], + 3 => [9, 10, 11] + ]; + return $data[$companyId] ?? []; + } +} \ No newline at end of file diff --git a/app/crmAdmin/logic/CustomerLogic.php b/app/crmAdmin/logic/CustomerLogic.php new file mode 100644 index 0000000..5c4192a --- /dev/null +++ b/app/crmAdmin/logic/CustomerLogic.php @@ -0,0 +1,34 @@ +find($id); + $customer->status = $status; + $res = $customer->save(); + + if($status == 1){ + $customer->restore(); + } + + + return $res; + } + + public function forceDelete(int $id ,string $status_desc = '') + { + $customer = Customer::withTrashed()->find($id); + //强制删除 +// $res = $customer->forceDelete(); + $customer->status = 3; + $customer->status_desc = $status_desc; + $res = $customer->save(); + return $res; + } +} \ No newline at end of file diff --git a/app/crmAdmin/service/CustomerService.php b/app/crmAdmin/service/CustomerService.php new file mode 100644 index 0000000..ecd2a17 --- /dev/null +++ b/app/crmAdmin/service/CustomerService.php @@ -0,0 +1,557 @@ +search($params); + return $customer; + } + + /** + * 查询列表数据 + * @param $params + * @param $export + * @return Customer[]|\Illuminate\Contracts\Pagination\LengthAwarePaginator|\Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Collection[]|\Illuminate\Pagination\LengthAwarePaginator + */ + public function search($params, $export = false) + { + +// $page = $request->input('page', 1); +// $perPage = $request->input('per_page', 10); + $perPage = $params['size'] ?? 10; + $page = $params['current'] ?? 1; + $keyword = $params['keyword'] ?? ''; + $recycle = $params['recycle'] ?? ''; + $follow = $params['follow'] ?? ''; + $follow_last = $params['follow_last'] ?? ''; + $tagId = $params['tag_id'] ?? []; + $user_id = $params['user_id'] ?? ''; + $seas = $params['seas'] ?? ''; //recyclea:管理端回收站,audit:待审核列表,seas:公海 + $scene = $params['scene'] ?? ''; + $channelId = $params['channel_id'] ?? ''; + $where = []; + + if ($scene == 'audit') { + $where[] = ['status', '=', 2]; + } elseif ($scene == 'recyclea') { + $where[] = ['status', '>', 2]; + } else { + $where[] = ['status', '=', 1]; + } + if ($seas == 'seas' || $scene == 'seas') { + $where[] = ['user_id', 0]; + } +// if($recycle == 'recycle'){ +// $where[] = ['user_id', $user_id]; +// } + + if (!empty($channelId)){ + $where[] = ['channel_id', $channelId]; + } + + $grade = []; + if (!Util::isEmpty($follow) && $follow == 0) { + $where[] = ['grade', '<=', 2]; + } elseif (!Util::isEmpty($follow) && $follow == 1) { + $grade = [3, 4]; + } elseif (!Util::isEmpty($follow) && $follow == 2) { + $where[] = ['grade', '=', 5]; + } + + if (!Util::isEmpty($follow_last) && $follow_last == 0) { + $where[] = ['follow_last', '>', strtotime('-1 day')]; + } elseif (!Util::isEmpty($follow_last) && $follow_last == 1) { + $where[] = ['follow_last', '>', strtotime('-3 day')]; + } elseif (!Util::isEmpty($follow_last) && $follow_last == 2) { + $where[] = ['follow_last', '>', strtotime('-7 day')]; + } + + $query = Customer::where($where); + + if (!empty($user_id)) { + $query->whereIn('user_id', explode(',', $user_id)); + } elseif ($user_id === 0 && $export) { + $query->where('user_id', 0); + } + + if (!empty($tagId)) { + //查询有$tagId 里任意一个标签的客户 + //$query->whereHas('crm_tag', function($query) use ($tagId){ + // $query->whereIn ('tag.id', $tagId); + //}); + //查询有$tagId 里所有标签的客户 + foreach ($tagId as $id) { + $query->whereHas('tags', function($query) use ($id){ + $query->where('tag_id', $id); + }); + } + } + if (!Util::isEmpty($keyword)) { + $query->where(function ($query) use ($keyword) { + $query->where('contacts', 'like', "%{$keyword}%") + ->orWhere('phone', 'like', "%{$keyword}%") + ->orWhere('email', 'like', "%{$keyword}%") + ->orWhere('WhatsApp', 'like', "%{$keyword}%") + ->orWhere('code', 'like', "%{$keyword}%"); + }); + } + if (!Util::isEmpty($grade)) { + $query->whereIn('grade', $grade); + } + + if ($recycle == 'recycle' || $scene == 'recycle' || $scene == 'audit') { + $query->onlyTrashed(); + } + if ($seas == 'seas' || $scene == 'recyclea') { + $query = $query->withTrashed(); + } + + if (!$export) { + $customer = $query->orderBy('id', 'desc')->paginate($perPage, ['*'], 'page', $page); + } else { + $customer = $query->orderBy('id', 'desc')->get(); + + return $customer; + } + if (!$export) { + foreach ($customer as &$item) { + $item = $this->getCustomer($item); + $item['desc_collect'] = $item['desc_collect'] ?? ''; + if ($seas == 'seas' || $scene == 'seas') { + $item['seas_time'] = time() - (empty($item['deleted_at'])?strtotime($item['updated_at']):strtotime($item['deleted_at'])); + } + if ($scene == 'audit') { //审核时查询举报人姓名 + $customerHis = CustomerHis::where('customer_id', $item->id)->orderBy('id', 'desc')->first(['user_his']); + $item['user_name'] = empty($customerHis['user_his']) ? '' : User::where('id',$customerHis['user_his'])->value('name'); + $audit = CustomerAudit::where('customer_id', $item->id)->orderBy('id', 'desc')->first(); + if (!empty($audit)&&!empty($audit['img'])) { + $audit = $audit?$audit->toArray():''; + $audit['img'] = explode(',', $audit['img']); + + $ossservice = new OssService(); + foreach ($audit['img'] as $k => $img) { + $audit['img'][$k] = $ossservice->generatePrivateUrl($img); + } + } + $item['audit'] = $audit; + } + if ($scene == 'recyclea' && $item['status'] == 3) { //回收站删除来源 + $item['del_source'] = '公海'; + }elseif ($scene == 'recyclea' && $item['status'] == 4) { + $item['del_source'] = '管理端'; + }elseif ($scene == 'recyclea' && $item['status'] == 5) { + $source = (!empty($item['user_id'])) ? (':' . User::where('id',$item['user_id'])->value('name')) : ''; + $item['del_source'] = '业务员' . $source; + } + } + } + unset($item); + + return $customer; + } + + public function export($params) + { + if(empty($params['user_id']) && empty($params['tag_id']) && empty($params['channel_id'])){ + $params['user_id'] = 0; + } + $customers = $this->search($params, true); + + // 创建新的Spreadsheet对象 + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + // 设置表头 + $headers = [ + 'A1' => '客户ID', + 'B1' => '联系人', + 'C1' => '电话', + 'D1' => '邮箱', + 'E1' => 'WhatsApp', + 'F1' => '性别', + 'G1' => '国家/地区', + 'H1' => '渠道来源', + 'I1' => '描述', + 'J1' => '所属人', + 'K1' => '录入人', + 'L1' => '详情', + 'M1' => '录入时间', + ]; + + // 设置表头样式 + foreach ($headers as $cell => $value) { + $sheet->setCellValue($cell, $value); + } + + // 设置表头样式 + $headerStyle = [ + 'font' => [ + 'bold' => true, + 'color' => ['rgb' => 'FFFFFF'], + ], + 'fill' => [ + 'fillType' => Fill::FILL_SOLID, + 'startColor' => ['rgb' => '4472C4'], + ], + 'alignment' => [ + 'horizontal' => Alignment::HORIZONTAL_CENTER, + 'vertical' => Alignment::VERTICAL_CENTER, + ], + 'borders' => [ + 'allBorders' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['rgb' => '000000'], + ], + ], + ]; + + $sheet->getStyle('A1:M1')->applyFromArray($headerStyle); + + // 填充数据 + $row = 2; + foreach ($customers as $customer) { + // 获取完整的客户信息 + $customerData = $this->getCustomer($customer); + + // 填充数据行 + $sheet->setCellValue('A' . $row, $customerData['id']); + $sheet->setCellValue('B' . $row, $customerData['contacts']); + $sheet->setCellValue('C' . $row, $customerData['phone']); + $sheet->setCellValue('D' . $row, $customerData['email']); + $sheet->setCellValue('E' . $row, $customerData['WhatsApp']); + $sheet->setCellValue('F' . $row, $customerData['sex_txt']); + $sheet->setCellValue('G' . $row, $customerData['country_name']); + $sheet->setCellValue('H' . $row, $customerData['channel_name']); + $sheet->setCellValue('I' . $row, $customerData['describe']); + $sheet->setCellValue('J' . $row, $customerData['user_name']); // 所属人姓名 + $sheet->setCellValue('K' . $row, $customerData['enter_user_name']); + $sheet->setCellValue('L' . $row, empty($customerData['desc_collect'])? '' :strip_tags($customerData['desc_collect'])); + $sheet->setCellValue('M' . $row, $customerData['created_at']->toDateTimeString()); + + $row++; + } + + // 设置数据行样式 + $dataStyle = [ + 'borders' => [ + 'allBorders' => [ + 'borderStyle' => Border::BORDER_THIN, + 'color' => ['rgb' => '000000'], + ], + ], + 'alignment' => [ + 'vertical' => Alignment::VERTICAL_CENTER, + ], + ]; + + $lastRow = $row - 1; + if ($lastRow >= 2) { + $sheet->getStyle('A2:K' . $lastRow)->applyFromArray($dataStyle); + } + + // 自动调整列宽 + foreach (range('A', 'K') as $column) { + $sheet->getColumnDimension($column)->setAutoSize(true); + } + + // 设置行高 + $sheet->getRowDimension(1)->setRowHeight(25); + + // 生成文件名 + $filename = '客户数据_' . date('Y-m-d_H-i-s') . '.xlsx'; + + // 创建输出目录 + $exportDir = runtime_path() . '/export/'; + if (!is_dir($exportDir)) { + mkdir($exportDir, 0755, true); + } + + $filepath = $exportDir . $filename; + + // 创建Excel写入器 + $writer = new Xlsx($spreadsheet); + $writer->save($filepath); +// $writer->save('php://output'); +// $excelContent = ob_get_clean(); +// return $excelContent; + +// 返回文件信息 + return [ + 'filename' => $filename, + 'filepath' => $filepath, + 'size' => filesize($filepath), + 'count' => count($customers) + ]; + } + + /** + * @param $params + * @return bool + */ + public function audit($params) + { + + $id = $params['id']; + + $logic = new CustomerLogic(); + $audit = CustomerAudit::where('customer_id', $id)->orderBy('id', 'desc')->first(); + if ($params['scene'] == 'delete') { + if(!empty($audit)){ + $audit->status = 3; + $audit->save(); + } + return $logic->forceDelete($params['id'],$audit->remark); + } else { + if(!empty($audit)){ + $audit->status = 2; + $audit->save(); + } + return $logic->status($params['id'], 1); + } + } + + /** + * @param $params + * @return bool + */ + public function transfer($params) + { + try { + Db::beginTransaction(); + $customerId = $params['customer_id']; + $userId = $params['user_id']; + + $query= Customer::whereIn('id',explode(',',$customerId)); + $customer = $query->get(); + $user = User::where(['id' => $userId])->first(); + + foreach ($customer as $item) { + //记录客户关系变化 + $his = CustomerHis::create([ + 'customer_id' => $item->id, + 'user_his' => $item->user_id, + 'user_id' => $userId, + ]); + + \app\crm\service\CustomerService::inquiryUpdate($item,$userId); + } + $data = [ + 'user_id' => $userId, + 'corp_id' => $user->corp_id, + 'obtain' => time(), + ]; + $res = $query->update($data); + + Db::commit(); + return $res; + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + } + + /** + * @param $params + * @return bool + */ + public function delete($params) + { + try { + Db::beginTransaction(); + $customerId = $params['customer_id']; + + $query= Customer::whereIn('id',explode(',',$customerId)); + $customer = $query->get(); + + foreach ($customer as $item) { + //记录客户关系变化 + $his = CustomerHis::create([ + 'customer_id' => $item->id, + 'user_his' => $item->user_id, + 'user_id' => 0, + ]); + + \app\crm\service\CustomerService::inquiryUpdate($item,0); + } + $res = $query->update(['user_id' => 0]); + + Db::commit(); + return $res; + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + } + + /** + * @param $customer + * @param $request + * @param $tag 是否返回客户标签 + * @return mixed + */ + public static function getCustomer($customer, $language = 'zh_CN', $tag = false) + { + $customer['grade_txt'] = Util::language($language, Constants::GRADE_EN[$customer['grade']], Constants::GRADE_ZH[$customer['grade']], Constants::GRADE_RU[$customer['grade']]); + $customer['magnitude'] = ($customer['magnitude']==0)?1:$customer['magnitude']; + $customer['magnitude_txt'] = Util::language($language, Constants::MAGNITUDE_EN[$customer['magnitude']], Constants::MAGNITUDE[$customer['magnitude']], Constants::MAGNITUDE_RU[$customer['magnitude']]); + $customer['sex_txt'] = Util::language($language, Constants::SEX_EN[$customer['sex']], Constants::SEX[$customer['sex']], Constants::SEX_RU[$customer['sex']]); + + if ($customer['grade'] <= 2) { + $customer['follow'] = 0; + $customer['follow_txt'] = Util::language($language, 'Following', '跟进中', 'Продолжение'); + } elseif ($customer['grade'] < 5) { + $customer['follow'] = 1; + $customer['follow_txt'] = Util::language($language, 'transacted', '已成交', 'Транзакция завершена'); + } else { + $customer['follow'] = 2; + $customer['follow_txt'] = Util::language($language, 'lost', '已流失', 'Потерянный'); + } + + $where = [ + 'customer_id' => $customer['id'] + ]; + $begin_follow = Follow::select('id', 'created_at', 'updated_at') + ->where($where) + ->min('created_at'); + + + $customer['begin_follow'] = Util::timeTotDate($begin_follow); +// $customer['created_at'] = Util::timeTotDate($customer['created_at']); +// $customer['updated_at'] = Util::timeTotDate($customer['updated_at']); + + $customer = Util::formatDate($customer, ['follow_next','follow_last']); + $nameLang = $language == 'en' ? 'name_en' : ($language == 'ru' ? 'name_ru' : 'name'); + $countryLang = $language == 'en' ? 'en_addr' : 'cn_addr'; +// $customer['car_type_name'] = empty($customer['car_type_id'])?'':CarType::where(['id'=>$customer['car_type_id']])->value($nameLang); + $customer['country_name'] = empty($customer['country_id']) ? '' : Country::where(['id' => $customer['country_id']])->value($countryLang); + $customer['channel_name'] = empty($customer['channel_id']) ? '' : Channel::where(['id' => $customer['channel_id']])->value($nameLang); +// $customer['last_follow'] = $last_follow; + + if ($tag) { + foreach ($customer['tags'] as &$tag) { + $tag['names'] = unserialize($tag['names']); + $tag['name'] = $tag['names'][$language]; + } + } + + + $customer['enter_user_name'] = empty($customer['enter_user_id']) ? '' : User::where('id',$customer['enter_user_id'])->value('name'); + $customer['user_name'] = empty($customer['user_id']) ? '公海' : User::where('id',$customer['user_id'])->value('name'); + + return $customer; + } + + + public function detail($params) + { + $id = $params['id']; + $customer = Customer::with('tags') + ->where('id', $id) + ->first(); + $customer = $this->getCustomer($customer); + + return $customer; + } + + public function follow($params) + { + $id = $params['id']; + $follow = Follow:: + where('customer_id', $id) + ->get(); + + $ossservice = new OssService(); + foreach ($follow as $item) { + $item = Util::formatDate($item,['created_at','updated_at','follow_time']); + $item['follow_time'] = empty($item['follow_time'])?'':$item['follow_time']; + $item['picture_full'] = empty($item->picture)?'':$ossservice->generatePrivateUrl($item->picture); + } + + return $follow; + } + + /** + * 离职员工客户分配 + * @param $params + * @return string[] + */ + public function allocation($params) + { + $corpId = $params['corp_id']; + $userId = explode(',', $params['user_id']); + + log::info('开始分配离职员工客户,corp_id:'.$corpId.' 被分配员工'. implode(',', $userId)); + + //查询离职客户 + $userResign = User::where('status', 2) + ->where('corp_id', $corpId) + ->get(['id','name']); + + $resignUerIds = []; + $userNames = []; + foreach ($userResign as $row) { + $resignUerIds[] = (int)$row->id; + $userNames[] = $row->name; + } + if (empty($userResign)) { + log::info('未匹配到离职用户'); + throw new BusinessException('未匹配到离职用户', 500); + } + log::info('匹配到离职用户数:' . count($resignUerIds) .' '. implode(',',$userNames)); + + $customerCount = Customer::whereIn('user_id', $resignUerIds) + ->orderBy('id', 'asc') + ->count(); + + if ($customerCount === 0) { + log::info('未找到离职员工相关的客户记录。'); + throw new BusinessException('未找到离职员工相关的客户记录', 500); + } + + // 初始化 UID 轮询器 + $uidRotator = new \app\common\utils\RedisUidRotator('allocation_customer_user'); + $uidRotator->setUids($userId, false); + + Customer::whereIn('user_id', $resignUerIds) + ->orderBy('id', 'asc') + ->chunkById(500, function ($customers) use ($uidRotator) { + foreach ($customers as $customer) { + // 获取下一个分配的UID + $newUserId = $uidRotator->next(); + $customer->user_id = $newUserId; + $customer->save(); + } + }, 'id'); + + + return [ + 'userCount' => count($resignUerIds), + 'customerCount' => $customerCount, + ]; + } +} \ No newline at end of file diff --git a/app/functions.php b/app/functions.php new file mode 100644 index 0000000..43c2f9a --- /dev/null +++ b/app/functions.php @@ -0,0 +1,392 @@ + $account] 或 ['email' => $account] + */ +if (!function_exists('normalizeAccount')) { + function normalizeAccount(string $account): array + { + // 使用FILTER_VALIDATE_EMAIL验证邮箱格式 + if (filter_var($account, FILTER_VALIDATE_EMAIL)) { + return ['email' => $account]; + } + + // 清理手机号中的非数字字符 + $cleanPhone = preg_replace('/[^0-9]/', '', $account); + + // 验证手机号格式(简单版:11位数字) + if (preg_match('/^1[3-9]\d{9}$/', $cleanPhone)) { + return ['phone' => $cleanPhone]; + } + + // 如果既不是邮箱也不是手机号,根据业务需求处理 + // 这里默认按手机号处理(可根据实际需求调整) + return ['phone' => $cleanPhone]; + } +} + +/** + * 公共html页面 + */ +if (!function_exists('htmlpage')) { + function htmlpage() + { + static $html; + if (!$html) { + $html = file_get_contents(public_path('mzl.html')); + } + return $html; + } +} +/** + * 获取配置 + */ +if (!function_exists('getConfig')) { + function getConfig($filePath = null) + { + // 构建文件路径 + $config_file = app_path($filePath); + // 检查文件是否存在 + if (!file_exists($config_file)) { + return []; + } + // 包含文件并获取返回的数组 + $config = include $config_file; + // 验证是否为数组 + if (!is_array($config)) { + return []; + } + return $config; + } +} + +/* + * 询单号生成 + */ +if (!function_exists('orderNum')) { + function orderNum() + { + return "SN".date('Ymd') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT); + } +} + +/* + * 调试用(console) + */ +if (!function_exists('dpr')) { + function dpr(\Throwable $e) + { + $contents = "=== === 报错打印 === ===\n"; + $contents .= "文件:".$e->getFile()."\n"; + $contents .= "行数:".$e->getLine()."\n"; + $contents .= "详情:".$e->getMessage()."\n"; + $contents .= "=== === 报错打印 === ===\n"; + var_dump($contents); + return $contents; + } +} + +/** + * 调试用(file) + */ +if (!function_exists('flg')) { + function flg($eventData = '', $isFs = true, $fileName = 'flg', $fileCatalog = 'flg') + { + if ($eventData == '') { + return false; + } + + if (is_array($eventData) || is_object($eventData)) { + $eventData = json_encode($eventData); + } + + $fileCatalog = substr(__FILE__, 0, strpos(__FILE__, 'app/')) . "runtime/" . $fileCatalog; + if (!is_dir($fileCatalog)) { + mkdir($fileCatalog, 0777, true); + exec('chown -R nginx:nginx ' . $fileCatalog); + } + + $fileName = $fileName . '-' . date('Ymd') . '.log'; + + if ($isFs) { + $data = [ + 'content' => $eventData, + 'time' => date('Y-m-d H:i:s', time()), + 'source' => getenv('ENVIRONMENT') + ]; + RedisQueueRedis::send("server", $data); + } + + return file_put_contents($fileCatalog . "/" . $fileName, date("Y-m-d H:i:s", time()) . "::" . $eventData . "\n", FILE_APPEND); + } +} + +/*** + * 发送飞书文本消息 + */ +if (!function_exists('sendFeishuTextMessage')) { + function sendFeishuTextMessage($webhookUrl, $message) { + $data = [ + 'msg_type' => 'text', + 'content' => [ + 'text' => $message + ] + ]; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $webhookUrl); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $response = curl_exec($ch); + curl_close($ch); + + return $response; + } +} + +/** + * 替换模板 + */ +if (!function_exists('replaceTpl')) { + function replaceTpl(array $data = [], string $tplKey = 'email') + { + if (!$data || !$tplKey) return ''; + $templatePath = tplUrl($tplKey); + $templatePath = app_path($templatePath); + $content = file_get_contents($templatePath); + foreach ($data as $key => $value) { + $content = str_replace('{{'.$key.'}}', $value, $content); + } + return $content; + } +} + +/** + * 模板路径 + */ +if (!function_exists('tplUrl')) { + function tplUrl(string $tplKey = '') + { + $urlArray = [ + 'email' => 'common/utils/scaffold/templates/Email.stub', + 'work_order' => 'common/utils/scaffold/templates/workOrder.stub', + 'payment_files' => 'common/utils/scaffold/templates/paymentFiles.stub', + ]; + return $urlArray[$tplKey]; + } +} + +/** + * 替换模板 + */ +if (!function_exists('InquiryFormat')) { + function InquiryFormat(array $data = []) + { + if (!$data) return ''; + $content = ''; + // foreach ($data as $key => $value) { + // $content .= $key . ':' . $value . "\r\n"; + // } + $content .= '编号:'.$data['order_no'] . "\r\n"; + $content .= '询单质量:'.$data['quality'] . "\r\n"; + $content .= '渠道:'.$data['channel'] . "\r\n"; + $content .= '邮箱:'.$data['email'] . "\r\n"; + $content .= '电话:'.$data['tel'] . "\r\n"; + $content .= 'WhatsApp:'.$data['whatsapp'] . "\r\n"; + $content .= '国家:'.$data['country'] . "\r\n"; + $content .= '联系人:'.$data['contacts'] . "\r\n"; + $content .= '录单人:'.$data['enter_user_id'] . "\r\n"; + $content .= '录单时间:'.$data['r_time'] . "\r\n"; + $content .= '内容:'.$data['content']; + return $content; + } +} + +/** + * 移除字符串中的括号及其内部内容(仅移除第一个匹配项) + * + * @param string $str 要处理的字符串 + * @return string 处理后的字符串 + */ +if (!function_exists('removeParentheses')) { + function removeParentheses(string $str): string + { + // 匹配模式:半角括号或全角括号及其内部内容 + $pattern = '/ + (?: # 非捕获分组开始 + \( # 匹配半角左括号 + [^)]* # 匹配除半角右括号外的任意字符 + \) # 匹配半角右括号 + )|(?: # 或 + \( # 匹配全角左括号 + [^)]* # 匹配除全角右括号外的任意字符 + \) # 匹配全角右括号 + ) # 非捕获分组结束 + /xu'; // x: 忽略空白 u: Unicode模式 + + return trim(preg_replace($pattern, '', $str, 1)); + } +} + +/** + * 规范hmtl富文本 + */ +if (!function_exists('normalizeRichText')) { + function normalizeRichText(string $html): string + { + $dom = new DOMDocument(); + + // 处理UTF-8编码和HTML5标签 + @$dom->loadHTML('' . $html, + LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + + // 设置编码并规范化文档 + $dom->encoding = 'UTF-8'; + $dom->normalizeDocument(); + + // 统一处理空白字符 + $xpath = new DOMXPath($dom); + foreach ($xpath->query('//text()') as $node) { + $normalized = preg_replace('/\s+/u', ' ', trim($node->nodeValue)); + $node->nodeValue = $normalized; + } + + // 标准化属性顺序(可选) + foreach ($xpath->query('//*[@*]') as $element) { + $attributes = $element->attributes; + $sorted = []; + foreach ($attributes as $attr) { + $sorted[$attr->nodeName] = $attr->nodeValue; + } + ksort($sorted); + + // 移除所有属性后按排序后顺序重新添加 + while ($attributes->length) { + $element->removeAttributeNode($attributes->item(0)); + } + foreach ($sorted as $name => $value) { + $element->setAttribute($name, $value); + } + } + + // 返回规范化后的HTML + $result = trim($dom->saveHTML()); + // 替换所有空白实体为普通空格 + return preg_replace('/\s+/u', ' ', $result); + } +} + + +/** + * 生成消息编号(唯一业务标识) + * 格式:年月日时分秒 + 微秒 + 随机数 + * 例如:20250820175530987654321 + * + * @return string + */ +if (!function_exists('generateMsgId')) { + function generateMsgId(): string + { + // 时间部分(精确到微秒) + $time = microtime(true); + $micro = sprintf("%06d", ($time - floor($time)) * 1e6); + $date = date('YmdHis', (int)$time); + + // 随机部分(3位) + $rand = mt_rand(100, 999); + + // 拼接:日期时间 + 微秒 + 随机数 + return $date . $micro . $rand; + } +} + +/** + * 生成sku_code + * 格式:年月日时分秒 + 微秒 + 随机数 + * 例如:20250820175530987654321 + * + * @return string + */ +if (!function_exists('skuCode')) { + function skuCode(): string + { + // 时间部分(精确到微秒) + $time = microtime(true); + $micro = sprintf("%06d", ($time - floor($time)) * 1e6); + $date = date('YmdHis', (int)$time); + + // 随机部分(3位) + $rand = mt_rand(100, 999); + + // 拼接:日期时间 + 微秒 + 随机数 + return $date . $micro . $rand; + } +} + + +/** + * 生成备货单编号(预占式,每天从1开始) + * + * @return string + */ +if (!function_exists('generateStockNo')) { + function generateStockNo(string $prefix = 'BH', int $serialLength = 3): string + { + $date = date('Ymd'); + $key = "stock:order:serial:{$date}"; + + // 原子递增获取当日流水号 + $serial = \support\Redis::incr($key); + + // 首次创建时设置过期时间:到明天凌晨 + 1小时(容错) + if ($serial === 1) { + $tomorrow = strtotime(date('Y-m-d', strtotime('+1 day'))); + $expireSeconds = $tomorrow - time() + 3600; // 多加1小时 + \support\Redis::expire($key, $expireSeconds); + } + + // 格式化流水号(补零) + $paddedSerial = str_pad($serial, $serialLength, '0', STR_PAD_LEFT); + + return $prefix . $date . $paddedSerial; + } +} + +/** + * 发送通知 + * + * @param string $title + * @param string $content + * @param string $url + * @param string $type + * @return void + */ +if (!function_exists('noticePush')) { + function noticePush(array $notice = []) + { + /* + 消息字段: + 'sender_id' //发送人 + 'sender_type' //发送人类型 + 'title' //标题 + 'content' //内容 + 'scene' //场景 + 'recipient' //接收人 + 'push_method' //推送方式 + // 如果需要同时 邮箱推送 增加推送邮箱字段 + 'email' //邮箱 + */ + \Webman\RedisQueue\Redis::send('notice', $notice); + } +} \ No newline at end of file diff --git a/app/middleware/AccessControl.php b/app/middleware/AccessControl.php new file mode 100644 index 0000000..2a93d79 --- /dev/null +++ b/app/middleware/AccessControl.php @@ -0,0 +1,26 @@ +method() == 'OPTIONS' ? response('') : $handler($request); + + // 给响应添加跨域相关的http头 + $response->withHeaders([ + 'Access-Control-Allow-Credentials' => 'true', + 'Access-Control-Allow-Origin' => $request->header('origin', '*'), + 'Access-Control-Allow-Methods' => $request->header('access-control-request-method', '*'), + 'Access-Control-Allow-Headers' => $request->header('access-control-request-headers', '*'), + ]); + + return $response; + } +} \ No newline at end of file diff --git a/app/middleware/Audit.php b/app/middleware/Audit.php new file mode 100644 index 0000000..deb99d5 --- /dev/null +++ b/app/middleware/Audit.php @@ -0,0 +1,114 @@ +getStatusCode(); + return $response; + } catch (\Throwable $e) { + $httpCode = $e->getCode() ?: 500; + $message = mb_substr($e->getMessage(), 0, 180); + $status = 0; + throw $e; + } finally { + $latency = (int)((microtime(true) - $start) * 1000); + $this->enqueue($request, $httpCode, $status, $message, $latency); + } + } + + + protected function enqueue(Request $req, int $httpCode, int $status, ?string $message, int $latencyMs): void + { + $buf = AuditBuffer::instance(); + + $user = $this->currentUser($req->user); + $paramsBrief = $this->briefParams($req->all()); + $ua = (string)($req->header('user-agent') ?? ''); + if (mb_strlen($ua) > 191) $ua = mb_substr($ua, 0, 191); + + $payload = [ + 'user_id' => $user['id'] ?? null, + 'user_name' => $user['name'] ?? null, + 'route' => $req->path(), + 'method' => $req->method(), + 'ip' => $req->getRealIp(), + 'ua' => $ua, + 'action' => $buf->action ?? $req->action, + 'status' => $status, + 'http_code' => $httpCode, + 'latency_ms' => $latencyMs, + 'params_brief' => $paramsBrief, // 数组,消费者再编码 + 'changes' => $buf->changes, // 数组,消费者再编码 + 'message' => $message, + 'ts' => time() + ]; + + try { + Queue::send('audit_log', $payload); // 异步 + } catch (\Throwable $e) { + // 遵循“绝不回落同步写”,仅记录错误 + Log::error('audit enqueue failed: '.$e->getMessage()); + } + } + + protected function briefParams(array $params): array + { + // $mask = ['password','pwd','token','secret','access_token','refresh_token','file','content','html']; + $mask = []; + $out = []; + foreach ($params as $k=>$v) { + if (in_array($k, $mask, true)) { $out[$k] = '***'; continue; } + if (is_array($v)) { $out[$k] = '[array('.count($v).')]'; continue; } + $s = (string)$v; + $out[$k] = mb_strlen($s) > 120 ? mb_substr($s, 0, 120).'…' : $s; + } + return $out; + } + + protected function inferAction(Request $req): ?string + { + $m = strtoupper($req->method()); + if ($m === 'POST') return 'create'; + if ($m === 'PUT' || $m === 'PATCH') return 'update'; + if ($m === 'DELETE') return 'delete'; + return 'view'; + } + + protected function currentUser($user): array + { + $user = (array)$user; + + if (!$user) { + return [ + 'id' => 0, + 'name' => 'guest' + ]; + } else { + if (!isset($user['uname'])) { + $user['uname'] = User::where('id', $user['uid'])->value('name'); + } + } + + return [ + 'id' => $user['uid'] ?? 0, + 'name' => $user['uname'] ?? 'guest' + ]; + } +} \ No newline at end of file diff --git a/app/middleware/AuthCheck.php b/app/middleware/AuthCheck.php new file mode 100644 index 0000000..a03e632 --- /dev/null +++ b/app/middleware/AuthCheck.php @@ -0,0 +1,50 @@ +controller); + $properties = $controller->getDefaultProperties(); + $noNeedLogin = $properties['noNeedLogin'] ?? []; + $noNeedAuth = $properties['noNeedAuth'] ?? []; + + if (in_array($request->action, $noNeedLogin)) { + // 白名单 + return $handler($request); + } else { + try { + $authHeader = $request->header('Fdesk-Auth-Token'); + if (!$authHeader || !preg_match('/Bearer\s(\S+)/', $authHeader, $matches)) { + $body = [ + 'status' => 'fail', + 'code' => 401, + 'message' => '未授权', + 'data' => [], + ]; + return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']); + } + $token = $matches[1]; + $request->user = (new Jwt())->verifyToken($token); + return $handler($request); + } catch (\Throwable $e) { + $body = [ + 'status' => 'fail', + 'code' => 401, + 'message' => $e->getMessage(), + 'data' => [], + ]; + return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']); + } + } + + return $handler($request); + } +} \ No newline at end of file diff --git a/app/middleware/BasicData.php b/app/middleware/BasicData.php new file mode 100644 index 0000000..9df57e7 --- /dev/null +++ b/app/middleware/BasicData.php @@ -0,0 +1,48 @@ +controller); + $noNeedHeaderData = $controller->getDefaultProperties()['noNeedHeaderData'] ?? []; + + if (in_array($request->action, $noNeedHeaderData)) { + // 白名单 + return $handler($request); + } else { + try { + $corpAbbr = $request->header('Fdesk-Corp-Abbr'); + $systemCode = $request->header('Fdesk-System-Code'); + $loginType = $request->header('Fdesk-Login-Type'); + $basicDataService = new BasicDataService(); + if ($basicDataService->checkAndCacheBaseParams($loginType, $systemCode, $corpAbbr)) { + $request->login_type = $loginType; + $request->system_code = $systemCode; + $request->corp_abbr = $corpAbbr; + $request->basic_data = [ + 'login_type' => $loginType, + 'system_code' => $systemCode, + 'corp_abbr' => $corpAbbr + ]; + } + return $handler($request); + } catch (\Throwable $th) { + $body = [ + 'status' => 'fail', + 'code' => 403, + 'message' => $th->getMessage(), + 'data' => [], + ]; + return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']); + } + } + } +} \ No newline at end of file diff --git a/app/middleware/StaticFile.php b/app/middleware/StaticFile.php new file mode 100644 index 0000000..fa8dbf7 --- /dev/null +++ b/app/middleware/StaticFile.php @@ -0,0 +1,42 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +namespace app\middleware; + +use Webman\MiddlewareInterface; +use Webman\Http\Response; +use Webman\Http\Request; + +/** + * Class StaticFile + * @package app\middleware + */ +class StaticFile implements MiddlewareInterface +{ + public function process(Request $request, callable $handler): Response + { + // Access to files beginning with. Is prohibited + if (strpos($request->path(), '/.') !== false) { + return response('

403 forbidden

', 403); + } + /** @var Response $response */ + $response = $handler($request); + // Add cross domain HTTP header + /*$response->withHeaders([ + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Credentials' => 'true', + ]);*/ + return $response; + } +} diff --git a/app/mobile/controller/InvItemController.php b/app/mobile/controller/InvItemController.php new file mode 100644 index 0000000..3606afa --- /dev/null +++ b/app/mobile/controller/InvItemController.php @@ -0,0 +1,55 @@ +service = new InvItemService(); + } + + public function preset(Request $request){ + try { + $res = $this->service->getList(); + $result = []; + foreach ($res as $value) { + $value['company_name'] = Corp::where('id', $value['company_id'])->value('name'); + $value['manage_user_name'] = User::where('id', $value['manage_user_id'])->value('name'); + $result[] = $value; + } + return $this->success($result, '获取物品列表'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['created_at'])) + $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + if (isset($data['updated_at'])) + $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + if (isset($data['company_id'])) + $data['company_name'] = Corp::where('id', $data['company_id'])->value('name'); + if (isset($data['manage_user_id'])) + $data['manage_user_name'] = User::where('id', $data['manage_user_id'])->value('name'); + return $data; + } +} \ No newline at end of file diff --git a/app/mobile/controller/InvRequisitionController.php b/app/mobile/controller/InvRequisitionController.php new file mode 100644 index 0000000..21d369e --- /dev/null +++ b/app/mobile/controller/InvRequisitionController.php @@ -0,0 +1,55 @@ +service = new InvRequisitionService(); + } + + /** + * 提交物品申请 + * + * @param Request $request + * @return mixed + */ + public function apply(Request $request) + { + try { + // 验证申请数据 + $data = $this->validate($this->service->getValidator(), $request->post(), 'apply'); + + // 处理申请逻辑 + $result = $this->service->serviceStore($request, $data, 'apply'); + + return $this->success($result, '申请提交成功'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/mobile/controller/InvRequisitionItemReturnController.php b/app/mobile/controller/InvRequisitionItemReturnController.php new file mode 100644 index 0000000..58df807 --- /dev/null +++ b/app/mobile/controller/InvRequisitionItemReturnController.php @@ -0,0 +1,56 @@ +service = new InvRequisitionItemReturnService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } + + + /** + * 物品兑换确认 + * + * @param Request $request + * @return mixed + */ + public function returnConfirm(Request $request) + { + try { + // 验证申请数据 + $data = $this->validate($this->service->getValidator(), $request->post(), 'returnConfirm'); + + // 处理申请逻辑 + $result = $this->service->serviceReturnConfirm($request, $data, 'returnConfirm'); + + return $this->success($result, '申请提交成功'); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/process/CheckWeb.php b/app/process/CheckWeb.php new file mode 100644 index 0000000..aeca44c --- /dev/null +++ b/app/process/CheckWeb.php @@ -0,0 +1,66 @@ +execute(); + }); + + new Crontab('*/50 * * * * *', function(){ + $userIds = User::where('status', 1)->pluck('id')->toArray(); + foreach ($userIds as $userId) { + $status = Redis::hGet('user:check:sign'.$userId, 'status'); + if ($status) { + continue; + } + $ws = new WsEvents(); + $ws->pushWebsocket('system.init.check', $userId, $ws->initCheck($userId)); + } + }); + } + + + public function tocurl($url, $header = null, $content = null) + { + $ch = curl_init(); + if(substr($url,0,5)=='https'){ + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 跳过证书检查 + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); // 从证书中检查SSL加密算法是否存在 + curl_setopt($ch, CURLOPT_SSLVERSION, 1); + } + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_URL, $url); + if (is_array($header)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $header); + } + curl_setopt($ch, CURLOPT_POST, true); + if (!empty($content)) { + if (is_array($content)) { + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($content)); + } else if (is_string($content)) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $content); + } + } + $response = curl_exec($ch); + $error=curl_error($ch); + if($error){ + die($error); + } + $header = curl_getinfo($ch); + curl_close($ch); + $data = array('header' => $header,'body' => $response); + return $data; + } +} \ No newline at end of file diff --git a/app/process/Emails.php b/app/process/Emails.php new file mode 100644 index 0000000..2679f00 --- /dev/null +++ b/app/process/Emails.php @@ -0,0 +1,58 @@ +uids(); + // }); + + // // 邮件详情 + // new Crontab('0 */35 * * * *', function(){ + // $email = new EmailsUtils(); + // $email->details(); + // }); + + // // 邮件处理 + // new Crontab('0 */40 * * * *', function(){ + // $email = new EmailsUtils(); + // $email->process(); + // }); + + if (getenv('ENVIRONMENT') === 'master') { + // 拉取新邮件 - 每小时的第20分钟0秒执行 + new Crontab('0 */20 * * * *', function(){ + $email = new EmailsUtils(); + $email->uids(); + }); + + // 邮件详情 - 每小时的第25分钟0秒执行 + new Crontab('0 */25 * * * *', function(){ + $email = new EmailsUtils(); + $email->details(); + }); + + // 邮件处理 - 每小时的第30分钟0秒执行 + new Crontab('0 */30 * * * *', function(){ + $email = new EmailsUtils(); + $email->process(); + }); + + // 邮件分派 - 每小时的第35分钟0秒执行 + new Crontab('0 */35 * * * *', function(){ + $email = new EmailsUtils(); + $email->fenpai(); + }); + } + } + +} \ No newline at end of file diff --git a/app/process/Http.php b/app/process/Http.php new file mode 100644 index 0000000..f462c3a --- /dev/null +++ b/app/process/Http.php @@ -0,0 +1,10 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +namespace app\process; + +use FilesystemIterator; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; +use Workerman\Timer; +use Workerman\Worker; + +/** + * Class FileMonitor + * @package process + */ +class Monitor +{ + /** + * @var array + */ + protected array $paths = []; + + /** + * @var array + */ + protected array $extensions = []; + + /** + * @var array + */ + protected array $loadedFiles = []; + + /** + * @var int + */ + protected int $ppid = 0; + + /** + * Pause monitor + * @return void + */ + public static function pause(): void + { + file_put_contents(static::lockFile(), time()); + } + + /** + * Resume monitor + * @return void + */ + public static function resume(): void + { + clearstatcache(); + if (is_file(static::lockFile())) { + unlink(static::lockFile()); + } + } + + /** + * Whether monitor is paused + * @return bool + */ + public static function isPaused(): bool + { + clearstatcache(); + return file_exists(static::lockFile()); + } + + /** + * Lock file + * @return string + */ + protected static function lockFile(): string + { + return runtime_path('monitor.lock'); + } + + /** + * FileMonitor constructor. + * @param $monitorDir + * @param $monitorExtensions + * @param array $options + */ + public function __construct($monitorDir, $monitorExtensions, array $options = []) + { + $this->ppid = function_exists('posix_getppid') ? posix_getppid() : 0; + static::resume(); + $this->paths = (array)$monitorDir; + $this->extensions = $monitorExtensions; + foreach (get_included_files() as $index => $file) { + $this->loadedFiles[$file] = $index; + if (strpos($file, 'webman-framework/src/support/App.php')) { + break; + } + } + if (!Worker::getAllWorkers()) { + return; + } + $disableFunctions = explode(',', ini_get('disable_functions')); + if (in_array('exec', $disableFunctions, true)) { + echo "\nMonitor file change turned off because exec() has been disabled by disable_functions setting in " . PHP_CONFIG_FILE_PATH . "/php.ini\n"; + } else { + if ($options['enable_file_monitor'] ?? true) { + Timer::add(1, function () { + $this->checkAllFilesChange(); + }); + } + } + + $memoryLimit = $this->getMemoryLimit($options['memory_limit'] ?? null); + if ($memoryLimit && ($options['enable_memory_monitor'] ?? true)) { + Timer::add(60, [$this, 'checkMemory'], [$memoryLimit]); + } + } + + /** + * @param $monitorDir + * @return bool + */ + public function checkFilesChange($monitorDir): bool + { + static $lastMtime, $tooManyFilesCheck; + if (!$lastMtime) { + $lastMtime = time(); + } + clearstatcache(); + if (!is_dir($monitorDir)) { + if (!is_file($monitorDir)) { + return false; + } + $iterator = [new SplFileInfo($monitorDir)]; + } else { + // recursive traversal directory + $dirIterator = new RecursiveDirectoryIterator($monitorDir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS); + $iterator = new RecursiveIteratorIterator($dirIterator); + } + $count = 0; + foreach ($iterator as $file) { + $count ++; + /** var SplFileInfo $file */ + if (is_dir($file->getRealPath())) { + continue; + } + // check mtime + if (in_array($file->getExtension(), $this->extensions, true) && $lastMtime < $file->getMTime()) { + $lastMtime = $file->getMTime(); + if (DIRECTORY_SEPARATOR === '/' && isset($this->loadedFiles[$file->getRealPath()])) { + echo "$file updated but cannot be reloaded because only auto-loaded files support reload.\n"; + continue; + } + $var = 0; + exec('"'.PHP_BINARY . '" -l ' . $file, $out, $var); + if ($var) { + continue; + } + // send SIGUSR1 signal to master process for reload + if (DIRECTORY_SEPARATOR === '/') { + if ($masterPid = $this->getMasterPid()) { + echo $file . " updated and reload\n"; + posix_kill($masterPid, SIGUSR1); + } else { + echo "Master process has gone away and can not reload\n"; + } + return true; + } + echo $file . " updated and reload\n"; + return true; + } + } + if (!$tooManyFilesCheck && $count > 1000) { + echo "Monitor: There are too many files ($count files) in $monitorDir which makes file monitoring very slow\n"; + $tooManyFilesCheck = 1; + } + return false; + } + + /** + * @return int + */ + public function getMasterPid(): int + { + if ($this->ppid === 0) { + return 0; + } + if (function_exists('posix_kill') && !posix_kill($this->ppid, 0)) { + echo "Master process has gone away\n"; + return $this->ppid = 0; + } + if (PHP_OS_FAMILY !== 'Linux') { + return $this->ppid; + } + $cmdline = "/proc/$this->ppid/cmdline"; + if (!is_readable($cmdline) || !($content = file_get_contents($cmdline)) || (!str_contains($content, 'WorkerMan') && !str_contains($content, 'php'))) { + // Process not exist + $this->ppid = 0; + } + return $this->ppid; + } + + /** + * @return bool + */ + public function checkAllFilesChange(): bool + { + if (static::isPaused()) { + return false; + } + foreach ($this->paths as $path) { + if ($this->checkFilesChange($path)) { + return true; + } + } + return false; + } + + /** + * @param $memoryLimit + * @return void + */ + public function checkMemory($memoryLimit): void + { + if (static::isPaused() || $memoryLimit <= 0) { + return; + } + $masterPid = $this->getMasterPid(); + if ($masterPid <= 0) { + echo "Master process has gone away\n"; + return; + } + + $childrenFile = "/proc/$masterPid/task/$masterPid/children"; + if (!is_file($childrenFile) || !($children = file_get_contents($childrenFile))) { + return; + } + foreach (explode(' ', $children) as $pid) { + $pid = (int)$pid; + $statusFile = "/proc/$pid/status"; + if (!is_file($statusFile) || !($status = file_get_contents($statusFile))) { + continue; + } + $mem = 0; + if (preg_match('/VmRSS\s*?:\s*?(\d+?)\s*?kB/', $status, $match)) { + $mem = $match[1]; + } + $mem = (int)($mem / 1024); + if ($mem >= $memoryLimit) { + posix_kill($pid, SIGINT); + } + } + } + + /** + * Get memory limit + * @param $memoryLimit + * @return int + */ + protected function getMemoryLimit($memoryLimit): int + { + if ($memoryLimit === 0) { + return 0; + } + $usePhpIni = false; + if (!$memoryLimit) { + $memoryLimit = ini_get('memory_limit'); + $usePhpIni = true; + } + + if ($memoryLimit == -1) { + return 0; + } + $unit = strtolower($memoryLimit[strlen($memoryLimit) - 1]); + $memoryLimit = (int)$memoryLimit; + if ($unit === 'g') { + $memoryLimit = 1024 * $memoryLimit; + } else if ($unit === 'k') { + $memoryLimit = ($memoryLimit / 1024); + } else if ($unit === 'm') { + $memoryLimit = (int)($memoryLimit); + } else if ($unit === 't') { + $memoryLimit = (1024 * 1024 * $memoryLimit); + } else { + $memoryLimit = ($memoryLimit / (1024 * 1024)); + } + if ($memoryLimit < 50) { + $memoryLimit = 50; + } + if ($usePhpIni) { + $memoryLimit = (0.8 * $memoryLimit); + } + return (int)$memoryLimit; + } + +} diff --git a/app/process/Task.php b/app/process/Task.php new file mode 100644 index 0000000..47fe78f --- /dev/null +++ b/app/process/Task.php @@ -0,0 +1,83 @@ +', time()], + ['warn', '<', 1] + ]; + $customer = Customer::where($where)->get(); + foreach ($customer as $item) { + $contacts = $item->contacts??$item['phone']; + $contacts = $contacts??$item['whatsApp']; + $contacts = $contacts??$item['email']; + $data = [ + 'sender_id' => 0, //发送人ID 0默认系统 + 'sender_type' => 'system', //发送者类型 user admin system + 'title' => '客户跟进提醒 - ' . $contacts, //标题 + 'content' => $contacts . ' 到达预定跟进时间,请及时跟进', //内容 + 'scene' => 1, //场景 0系统公共 1系统通知 + 'recipient' => [$item->user_id], //接收人 空为全部 + 'push_method' => 0 //推送方式0系统推送1手动推送 + ]; + \Webman\RedisQueue\Redis::send("notice",$data); + + $item->warn = 1; + $item->save(); + } + + Log::info('send follow end',[time()]); + }); + + // 检查入账资料是否提交 + new Crontab('* */70 * * * *', function () { + $G = date('G'); + if ($G > 22 || $G < 8) { + return; + } + $where = [ + ['submit_files', '=', ''], + ['warn', '<', 2], + ['created_at', '>', time() - 60 * 60 * 48], + ['created_at', '<', time() - 60 * 60 * 24], + ]; + $payment = FinPaymentApply::where($where)->orderBy('id', 'desc')->get(); + foreach ($payment as $item) { + Log::info('fin_payment_apply submit_files ', [$item->toArray()]); + $notice = [ + 'code' => $item->apply_no, + 'payment_entity' => $item->payment_entity ?? $item->remitter, + 'content' => '认领收款单后请在24小时内提交入账资料', + 'reply' => '', + ]; + $email = []; + $email[] = User::where('id', $item->claimed_by)->value('email'); + $email[] = User::where('id', $item->doc_user_id)->value('email'); + $email = array_unique($email); + \app\common\utils\email\MailSend::send($email, '【XYH】付款记录', replaceTpl($notice, 'payment_files')); + + $item->warn = ++$item->warn; + $item->save(); + } + + }); + } + +} \ No newline at end of file diff --git a/app/queue/redis/ApprovalCallback.php b/app/queue/redis/ApprovalCallback.php new file mode 100644 index 0000000..14a4670 --- /dev/null +++ b/app/queue/redis/ApprovalCallback.php @@ -0,0 +1,83 @@ + $this->approvalStockOrders($data), + 'purchase_orders' => $this->approvalPurchaseOrders($data), + 'stock_orders_nullify' => '', + default => throw new BusinessException('未知的审批类型,无法执行回调操作') + }; + return true; + } catch (\Throwable $th) { + flg('审批回调异常:'.$th->getFile()."|".$th->getLine()."|".$th->getMessage(), false); + throw new BusinessException($th->getFile()."|".$th->getLine()."|".$th->getMessage()); + } + } + + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + } + + /** + * 备货单处理 + */ + private function approvalStockOrders($data) + { + // 生成PDF文件 + $service = new \app\crm\service\StockOrdersService(); + $result = $service->createdPdfFile($data['biz_id']); + return $result; + } + + /** + * 采购单处理 + */ + private function approvalPurchaseOrders($data) + { + $udata['id'] = $data['biz_id']; + if ($data['status'] === 'approve') { + $udata['status'] = 2; + } + + if ($data['status'] === 'reject') { + $udata['status'] = 3; + } + + $service = new \app\common\service\PurchaseOrdersService(); + $result = $service->updateStatus($udata); + return $result; + } +} \ No newline at end of file diff --git a/app/queue/redis/AuditLog.php b/app/queue/redis/AuditLog.php new file mode 100644 index 0000000..dc8ef88 --- /dev/null +++ b/app/queue/redis/AuditLog.php @@ -0,0 +1,72 @@ + $data['trace_id'] ?? null, + 'user_id' => $data['user_id'] ?? null, + 'user_name' => $data['user_name'] ?? null, + 'route' => $data['route'] ?? null, + 'method' => $data['method'] ?? null, + 'ip' => $data['ip'] ?? null, + 'ua' => $data['ua'] ?? null, + 'action' => $data['action'] ?? null, + 'status' => (int)($data['status'] ?? 1), + 'http_code' => (int)($data['http_code'] ?? 200), + 'latency_ms' => $data['latency_ms'] ?? null, + 'params_brief' => !empty($data['params_brief']) ? json_encode($data['params_brief'], JSON_UNESCAPED_UNICODE) : null, + 'changes' => !empty($data['changes']) ? json_encode($data['changes'], JSON_UNESCAPED_UNICODE) : null, + 'message' => $data['message'] ?? null, + 'created_at' => $now, + 'updated_at' => $now, + ]; + + Db::table('sys_op_log')->insert($row); + } catch (\Throwable $e) { + Log::error('audit_log consume error: '.$e->getMessage()); + return false; // 允许重试与失败处理 + } + return true; + } + + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + + Log::error('audit_log message failed after retries: '.json_encode($package, JSON_UNESCAPED_UNICODE)); + } +} \ No newline at end of file diff --git a/app/queue/redis/EmailMessage.php b/app/queue/redis/EmailMessage.php new file mode 100644 index 0000000..f170a78 --- /dev/null +++ b/app/queue/redis/EmailMessage.php @@ -0,0 +1,51 @@ +getContent($data['id']); + + MailSend::send($data['email'], $data['title'], replaceTpl($content)); + } catch (\Throwable $th) { + throw $th; + } + } + + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + } +} \ No newline at end of file diff --git a/app/queue/redis/FeishuMessage.php b/app/queue/redis/FeishuMessage.php new file mode 100644 index 0000000..7aa510c --- /dev/null +++ b/app/queue/redis/FeishuMessage.php @@ -0,0 +1,131 @@ +where('id', $data['id'])->first(); + + // 跟单人 + $reInfo = User::where('id', $info->user_id)->first(); + + // 飞书应用 + $feishuApp = new CorpFeishuApp(); + $feishuAppInfo = $feishuApp->where('corp_id', $reInfo->corp_id)->where('system_id', 1)->first(); + + // 推送数据 + $options = [ + 'contacts' => $info->contacts, + 'follow_last' => $info->follow_last ? date('Y-m-d H:i', $info->follow_last) : '', + 'follow_next' => $info->follow_next ? date('Y-m-d H:i', $info->follow_next) : '', + + 'app_id' => $feishuAppInfo->app_id, + 'app_secret' => $feishuAppInfo->app_secret, + 'state' => $feishuAppInfo->state, + 'msg_type' => 'interactive', + 'receive_id_type' => 'open_id', + 'receive_id' => $reInfo->open_id + ]; + } + + if ($data['category'] === 'inquiry') { + // 询单信息 + $inquiryLogService = new InquiryLogService(); + $info = $inquiryLogService->getInquiryLogBy($data['id']); + + // 接受者处理 + if ($data['receive_type'] == 'group') { + $appId = 'cli_a6183b882cb8500d'; + $appSecret = 'VqYib48u4EtDIa6nG9qH3f0fYCA1bCvs'; + $state = 'hesu'; + $receiveId = 'oc_a52d9f89ad76735279088c78beade8dc'; + $receiveIdType = 'chat_id'; + } else { + // 跟单人 + $reInfo = User::where('id', $info->recipient)->first(); + // 飞书应用 + $feishuApp = new CorpFeishuApp(); + $feishuAppInfo = $feishuApp->where('corp_id', $reInfo->corp_id)->where('system_id', 1)->first(); + // 推送信息 + $appId = $feishuAppInfo->app_id; + $appSecret = $feishuAppInfo->app_secret; + $state = $feishuAppInfo->state; + $receiveId = $reInfo->open_id; + $receiveIdType = 'open_id'; + } + + // 推送数据 + $options = [ + 'order_no' => $info->order_no, + 'quality' => $inquiryLogService->qualityName($info->quality), + 'channel' => $info->channelInfo ? $info->channelInfo->name : '', + 'country' => $info->countryInfo ? $info->countryInfo->cn_addr : '', + 'enter_user' => $info->enter ? $info->enter->name : '', + 'r_time' => $info->r_time ? date('Y-m-d H:i:s', $info->r_time) : '', + 't_time' => $info->t_time ? date('Y-m-d H:i:s', $info->t_time) : '', + + 'app_id' => $appId, + 'app_secret' => $appSecret, + 'state' => $state, + 'msg_type' => 'interactive', + 'receive_id_type' => $receiveIdType, + 'receive_id' => $receiveId + ]; + } + + $feishuMsg = new FeishuMsg($options); + $feishuMsg->messageSend($data['scene'], $options); + } catch (\Throwable $th) { + throw $th; + } + } + + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + } +} \ No newline at end of file diff --git a/app/queue/redis/InquiryToCrmNew.php b/app/queue/redis/InquiryToCrmNew.php new file mode 100644 index 0000000..2ad666f --- /dev/null +++ b/app/queue/redis/InquiryToCrmNew.php @@ -0,0 +1,222 @@ +where('id', $data['id'])->first(); + + // 无论添加 或 更新,询单必然要存在 + if (!$info) { + throw new BusinessException('询盘不存在:' . $data['id']); + } + + if ($info->status == 0 && $info->recipient == 0) { + // 如果询单未发、未分,则不与crm同步,不做任何处理 + return true; + } + + // 更改库信息 + $this->syncCustomer($info); + } catch (\Throwable $th) { + throw $th; + } + } + + public function pushWs($title, $content, $recipient = []) + { + $wsdata = [ + 'sender_id' => 0, + 'sender_type' => 'system', + 'title' => $title, + 'content' => $content, + 'scene' => 1, + 'recipient' => $recipient + ]; + // 异步推送 + \Webman\RedisQueue\Redis::send('notice', $wsdata); + } + + public function pushEmail($id, $title, $email) + { + $options = [ + 'id' => $id, + 'email' => $email, + 'title' => $title, + ]; + \Webman\RedisQueue\Redis::send('email-message', $options); + } + + public function syncCustomer(Model $info) + { + $uInfo = $this->recipientInfo($info); + $crmCustomerInfo = CrmCustomer::where('sn', $info->order_no)->first(); + if ($crmCustomerInfo) { + + // 消息推送 + if ($info->status == 2 && empty($crmCustomerInfo->user_id)) { + // $notice = '分配'; + $title = '[询单·分配] 新的客户线索【' . $info->order_no . '】,请及时处理'; + $content = '[询单·分配] 新的客户线索【' . $info->order_no . '】,请及时处理'; + $recipient = [$info->recipient]; + // $this->pushWs($title, $content, $recipient); + $this->pushEmail($info->id, $title, $uInfo['uinfo']['email']); + } else { + if ($info->status == 2 && $crmCustomerInfo->user_id == $info->recipient) { + // $notice = '更新'; + $title = '[询单·更新] 客户线索' . $info->order_no . '有新的调整,请及时处理'; + $content = '[询单·更新] 客户线索' . $info->order_no . '有新的调整,请及时处理'; + $recipient = [$info->recipient]; + // $this->pushWs($title, $content, $recipient); + $this->pushEmail($info->id, $title, $uInfo['uinfo']['email']); + } else { + // $notice = '重新分配'; + $title = '[询单·分配] 新的客户线索【' . $info->order_no . '】,请及时处理'; + $content = '[询单·分配] 新的客户线索【' . $info->order_no . '】,请及时处理'; + $recipient = [$info->recipient]; + // $this->pushWs($title, $content, $recipient); + $this->pushEmail($info->id, $title, $uInfo['uinfo']['email']); + } + } + + $udata = [ + 'corp_id' => $uInfo['corpId'], + 'user_id' => $info->recipient, + 'channel_id' => $info->channel, + 'country_id' => $info->country, + 'magnitude' => $info->quality, + 'email' => $info->email, + 'phone' => $info->tel, + 'WhatsApp' => $info->whatsapp, + 'contacts' => $info->contacts, + 'desc_collect' => $info->content, + ]; + if (!$crmCustomerInfo->update($udata)) { + throw new BusinessException('同步失败,更新客户线索失败'); + } + } else { + $crmCustomer = new CrmCustomer(); + $crmCustomer->sn = $info->order_no; + $crmCustomer->corp_id = $uInfo['corpId']; + $crmCustomer->user_id = $uInfo['recipient']; + $crmCustomer->type = 1; + $crmCustomer->channel_id = $info->channel; + $crmCustomer->country_id = $info->country; + $crmCustomer->magnitude = $info->quality; + $crmCustomer->email = $info->email; + $crmCustomer->phone = $info->tel; + $crmCustomer->WhatsApp = $info->whatsapp; + $crmCustomer->contacts = $info->contacts; + $crmCustomer->desc_collect = $info->content; + $crmCustomer->enter_user_id = $info->enter_user_id; + $crmCustomer->obtain = time(); + if(empty($uInfo['recipient'])){ + $crmCustomer->deleted_at = time(); + } + // 修改同步CRM状态 + $info->sync_crm = 1; + // 保存 + if (!$crmCustomer->save() || !$info->save()) { + throw new BusinessException('同步失败,创建客户线索失败'); + } + + // 消息推送 + if ($info->status == 1) { + // $notice = '发布'; + $title = '[询单·公海] 新的客户线索【' . $info->order_no . '】发布'; + $content = '[询单·公海] 新的客户线索【' . $info->order_no . '】发布'; + $recipient = [$info->recipient]; + // $this->pushWs($title, $content); + $this->pushEmail($info->id, $title, $uInfo['uinfo']['email']); + } + + if ($info->status == 2) { + // $notice = '分配'; + $title = '[询单·分配] 新的客户线索【' . $info->order_no . '】,请及时处理'; + $content = '[询单·分配] 新的客户线索【' . $info->order_no . '】,请及时处理'; + $recipient = [$info->recipient]; + // $this->pushWs($title, $content, $recipient); + $this->pushEmail($info->id, $title, $uInfo['uinfo']['email']); + } + } + return true; + } + + public function recipientInfo(Model $info) + { + $recipient = 0; + $corpId = 0; + $uinfo = null; + if (!empty($info->recipient)) { + $recipient = $info->recipient; + $uinfo = User::where('id', $info->recipient)->first(); + if ($uinfo) { + $corpId = $uinfo->corp_id; + } + } + return [ + 'recipient' => $recipient, + 'corpId' => $corpId, + 'uinfo' => $uinfo + ]; + } + + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + } +} \ No newline at end of file diff --git a/app/queue/redis/InquiryToCrmNew_bak.php b/app/queue/redis/InquiryToCrmNew_bak.php new file mode 100644 index 0000000..e2b42f8 --- /dev/null +++ b/app/queue/redis/InquiryToCrmNew_bak.php @@ -0,0 +1,207 @@ +where('id', $data['id'])->first(); + + // 无论添加 或 更新,询单必然要存在 + if (!$info) { + throw new BusinessException('询盘不存在:' . $data['id']); + } + + if ($info->status == 0 && $info->recipient == 0) { + // 如果询单未发、未分,则不与crm同步,不做任何处理 + return true; + } + + // 更改库信息 + $this->syncCustomer($info); + } catch (\Throwable $th) { + throw $th; + } + } + + public function pushWs($title, $content, $recipient = []) + { + $wsdata = [ + 'sender_id' => 0, + 'sender_type' => 'system', + 'title' => $title, + 'content' => $content, + 'scene' => 1, + 'recipient' => $recipient + ]; + // 异步推送 + \Webman\RedisQueue\Redis::send('notice', $wsdata); + } + + public function syncCustomer(Model $info) + { + $uInfo = $this->recipientInfo($info); + $crmCustomerInfo = CrmCustomer::where('sn', $info->order_no)->first(); + if ($crmCustomerInfo) { + + // 消息推送 + if ($info->status == 2 && empty($crmCustomerInfo->user_id)) { + // $notice = '分配'; + $title = '[询单·分配] 新的客户线索【' . $info->order_no . '】,请及时处理'; + $content = '[询单·分配] 新的客户线索【' . $info->order_no . '】,请及时处理'; + $recipient = [$info->recipient]; + $this->pushWs($title, $content, $recipient); + } else { + if ($info->status == 2 && $crmCustomerInfo->user_id == $info->recipient) { + // $notice = '更新'; + $title = '[询单·更新] 客户线索' . $info->order_no . '有新的调整,请及时处理'; + $content = '[询单·更新] 客户线索' . $info->order_no . '有新的调整,请及时处理'; + $recipient = [$info->recipient]; + $this->pushWs($title, $content, $recipient); + } else { + // $notice = '重新分配'; + $title = '[询单·分配] 新的客户线索【' . $info->order_no . '】,请及时处理'; + $content = '[询单·分配] 新的客户线索【' . $info->order_no . '】,请及时处理'; + $recipient = [$info->recipient]; + $this->pushWs($title, $content, $recipient); + } + } + + $udata = [ + 'corp_id' => $uInfo['corpId'], + 'user_id' => $info->recipient, + 'channel_id' => $info->channel, + 'country_id' => $info->country, + 'magnitude' => $info->quality, + 'email' => $info->email, + 'phone' => $info->tel, + 'WhatsApp' => $info->whatsapp, + 'contacts' => $info->contacts, + 'desc_collect' => $info->content, + ]; + if (!$crmCustomerInfo->update($udata)) { + throw new BusinessException('同步失败,更新客户线索失败'); + } + } else { + $crmCustomer = new CrmCustomer(); + $crmCustomer->sn = $info->order_no; + $crmCustomer->corp_id = $uInfo['corpId']; + $crmCustomer->user_id = $uInfo['recipient']; + $crmCustomer->type = 1; + $crmCustomer->channel_id = $info->channel; + $crmCustomer->country_id = $info->country; + $crmCustomer->magnitude = $info->quality; + $crmCustomer->email = $info->email; + $crmCustomer->phone = $info->tel; + $crmCustomer->WhatsApp = $info->whatsapp; + $crmCustomer->contacts = $info->contacts; + $crmCustomer->desc_collect = $info->content; + $crmCustomer->enter_user_id = $info->enter_user_id; + $crmCustomer->obtain = time(); + if(empty($uInfo['recipient'])){ + $crmCustomer->deleted_at = time(); + } + // 修改同步CRM状态 + $info->sync_crm = 1; + // 保存 + if (!$crmCustomer->save() || !$info->save()) { + throw new BusinessException('同步失败,创建客户线索失败'); + } + + // 消息推送 + if ($info->status == 1) { + // $notice = '发布'; + $title = '[询单·公海] 新的客户线索【' . $info->order_no . '】发布'; + $content = '[询单·公海] 新的客户线索【' . $info->order_no . '】发布'; + $recipient = [$info->recipient]; + $this->pushWs($title, $content); + } + + if ($info->status == 2) { + // $notice = '分配'; + $title = '[询单·分配] 新的客户线索【' . $info->order_no . '】,请及时处理'; + $content = '[询单·分配] 新的客户线索【' . $info->order_no . '】,请及时处理'; + $recipient = [$info->recipient]; + $this->pushWs($title, $content, $recipient); + } + } + return true; + } + + public function recipientInfo(Model $info) + { + $recipient = 0; + $corpId = 0; + $uinfo = null; + if (!empty($info->recipient)) { + $recipient = $info->recipient; + $uinfo = User::where('id', $info->recipient)->first(); + if ($uinfo) { + $corpId = $uinfo->corp_id; + } + } + return [ + 'recipient' => $recipient, + 'corpId' => $corpId, + 'uinfo' => $uinfo + ]; + } + + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + } +} \ No newline at end of file diff --git a/app/queue/redis/Notice.php b/app/queue/redis/Notice.php new file mode 100644 index 0000000..f166085 --- /dev/null +++ b/app/queue/redis/Notice.php @@ -0,0 +1,61 @@ +pushNotice($data); + + // 邮件消息 + if (isset($data['email']) && $data['email'] === 1) { + $emails = $service->getUserEmailsById($data['recipient']); + foreach ($emails as $email) { + if (empty($email)) { + continue; + } + \app\common\utils\email\MailSend::send($email, $data['title'], $data['content']); + } + } + return true; + } catch (\Throwable $th) { + flg("消息推送异常:".$th->getFile()."|".$th->getLine()."|".$th->getMessage()); + throw new BusinessException($th->getFile()."|".$th->getLine()."|".$th->getMessage()); + } + } + + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + } +} \ No newline at end of file diff --git a/app/queue/redis/PermAgt.php b/app/queue/redis/PermAgt.php new file mode 100644 index 0000000..42a4898 --- /dev/null +++ b/app/queue/redis/PermAgt.php @@ -0,0 +1,95 @@ + $userId, + 'role_id' => 1 + ], + [ + 'user_id' => $userId, + 'role_id' => 2 + ], + ]; + $systems = [ + [ + 'user_id' => $userId, + 'system_id' => 1 + ], + [ + 'user_id' => $userId, + 'system_id' => 2 + ], + [ + 'user_id' => $userId, + 'system_id' => 3 + ], + ]; + } else { + $roles = [ + 'user_id' => $userId, + 'role_id' => 2 + ]; + $systems = [ + [ + 'user_id' => $userId, + 'system_id' => 2 + ], + [ + 'user_id' => $userId, + 'system_id' => 3 + ], + ]; + } + + Db::table('user_roles')->insert($roles); + Db::table('user_systems')->insert($systems); + return true; + } catch (\Throwable $th) { + throw new BusinessException($th->getFile()."|".$th->getLine()."|".$th->getMessage()); + } + } + + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + } +} \ No newline at end of file diff --git a/app/queue/redis/Server.php b/app/queue/redis/Server.php new file mode 100644 index 0000000..2d9814e --- /dev/null +++ b/app/queue/redis/Server.php @@ -0,0 +1,57 @@ +getFile()."|".$th->getLine()."|".$th->getMessage()); + throw new BusinessException($th->getFile()."|".$th->getLine()."|".$th->getMessage()); + } + } + + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + } +} \ No newline at end of file diff --git a/app/queue/redis/StockOrderPdf.php b/app/queue/redis/StockOrderPdf.php new file mode 100644 index 0000000..057a761 --- /dev/null +++ b/app/queue/redis/StockOrderPdf.php @@ -0,0 +1,51 @@ +createdPdfFile($data['id']); + + return true; + } catch (\Throwable $th) { + var_dump('4 :'.$th->getFile()."|".$th->getLine()."|".$th->getMessage()); + throw new BusinessException($th->getFile()."|".$th->getLine()."|".$th->getMessage()); + } + } + + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + } +} \ No newline at end of file diff --git a/app/queue/redis/WsMsg.php b/app/queue/redis/WsMsg.php new file mode 100644 index 0000000..dcd15ce --- /dev/null +++ b/app/queue/redis/WsMsg.php @@ -0,0 +1,68 @@ + 186, + 3 => 226, + ]; + + if (isset($data['is_first']) && $data['is_first'] === true) { + $num = (new \app\common\utils\approval\service\ApprovalFlowService())->getInitTodoCount($data['user_id']); + } else { + $num = $data['num']; + } + + if ($data['event'] === 'approval.todo.counts') { + $systemIds = UserSystems::where('user_id', $data['user_id'])->pluck('system_id')->toArray(); + if (count($systemIds) > 0) { + foreach ($systemIds as $systemId) { + if (isset($ly[$systemId])) { + $chain = \app\common\service\MenuBadgeService::buildBadgeChain(menuId: $ly[$systemId], leafCount: $num); + (new \app\common\utils\WsEvents())->pushWebsocket('approval.todo.counts', $data['user_id'], $chain); + } + } + } + } + } catch (\Throwable $th) { + dpr($th); + throw $th; + } + } + + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + } +} \ No newline at end of file diff --git a/app/queue/utils/Translat.php b/app/queue/utils/Translat.php new file mode 100644 index 0000000..3000756 --- /dev/null +++ b/app/queue/utils/Translat.php @@ -0,0 +1,21 @@ + $header,'body' => $response); + return $data; + } + + + public function xfyun($text = "中华人民共和国于1949年成立", $from = "cn", $to = "en") + { + //在控制台-我的应用-机器翻译获取 + $app_id = "3d46198c"; + //在控制台-我的应用-机器翻译获取 + $api_sec = "ZWY2MmUyNjJjYmFkOTFlYTVmYWVhMGYy"; + //在控制台-我的应用-机器翻译获取 + $api_key = "b6d805f240176ecaea85130cb5f2bca1"; + // 机器翻译接口地址 + $url = "https://ntrans.xfyun.cn/v2/ots"; + //body组装 + // $text = "中华人民共和国于1949年成立";//待翻译文本 + $body = json_encode($this->getBody($app_id, $from, $to, $text)); + + // 组装http请求头 + $date =gmdate('D, d M Y H:i:s') . ' GMT'; + + $digestBase64 = "SHA-256=".base64_encode(hash("sha256", $body, true)); + $builder = sprintf("host: %s +date: %s +POST /v2/ots HTTP/1.1 +digest: %s", "ntrans.xfyun.cn", $date, $digestBase64); + // echo($builder); + $sha = base64_encode(hash_hmac("sha256", $builder, $api_sec, true)); + + $authorization = sprintf("api_key=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"", $api_key,"hmac-sha256", + "host date request-line digest", $sha); + + $header = [ + "Authorization: ".$authorization, + 'Content-Type: application/json', + 'Accept: application/json,version=1.0', + 'Host: ntrans.xfyun.cn', + 'Date: ' .$date, + 'Digest: '.$digestBase64 + ]; + $response = $this->tocurl($url, $header, $body); + + // var_dump($response['body']); + return $response; + } + + + public function getBody($app_id, $from, $to, $text) + { + $common_param = [ + 'app_id' => $app_id + ]; + + $business = [ + 'from' => $from, + 'to' => $to, + ]; + + $data = [ + "text" => base64_encode($text) + ]; + + return $body = [ + 'common' => $common_param, + 'business' => $business, + 'data' => $data + ]; + } + + // 翻译 + public function fanyi($text = "", $from = "", $to = "") + { + try { + $res = $this->xfyun($text, $from, $to); + $data = json_decode($res["body"], true); + if ($data["code"] === 0) { + return $data["data"]["result"]; + } else { + $this->error = $data["message"]; + Log::error("XunfeiFanyi 失败: ".$data["message"],[$data]); + return false; + } + } catch (\Throwable $th) { + $this->error = $th->getMessage(); + Log::error("XunfeiFanyi 错误: ".$th->getMessage()); + return false; + } + } + + // 异常 + public function error() + { + return $this->error; + } + +} \ No newline at end of file diff --git a/app/ticket/controller/AttachmentController.php b/app/ticket/controller/AttachmentController.php new file mode 100644 index 0000000..94e1724 --- /dev/null +++ b/app/ticket/controller/AttachmentController.php @@ -0,0 +1,11 @@ +scene($scene); + } + // 执行数据验证 + if (!$validator->check($data)) { + // 验证失败,抛出业务异常,异常信息为验证器返回的错误信息 + throw new BusinessException($validator->getError()); + } + // 验证成功,返回原始数据 + return $data; + } + + public function page($result){ + $current = $result->currentPage(); + $total = $result->total(); + $size = $result->perPage(); + $result = $result->toArray(); + $result['current'] = $current; + $result['total'] = $total; + $result['size'] = $size; + return $result; + } +} \ No newline at end of file diff --git a/app/ticket/controller/CategorieController.php b/app/ticket/controller/CategorieController.php new file mode 100644 index 0000000..84e8bd7 --- /dev/null +++ b/app/ticket/controller/CategorieController.php @@ -0,0 +1,64 @@ +list($request->all()); + return $this->success($this->page($result)); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function getAll(Request $request) + { + try { + $result = (new CategorieService())->getAll($request->all()); + return $this->success($result); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function add(Request $request) + { + try { + return (new CategorieService())->add($request->all()) + ? $this->success() + : $this->error(); + } catch (\Throwable $th) { + return $this->error($th->getMessage().' '.$th->getLine().' '.$th->getFile()); + } + } + + public function edit(Request $request) + { + try { + $result = (new CategorieService())->edit($request->all()); + return $result + ? $this->success($result) + : $this->error(); + } catch (\Throwable $th) { + return $this->error($th->getMessage().' '.$th->getLine().' '.$th->getFile()); + } + } + + public function delete(Request $request) + { + try { + $result = (new CategorieService())->delete($request->input('id')); + return $result + ? $this->success($result) + : $this->error(); + } catch (\Throwable $th) { + return $this->error($th->getMessage().' '.$th->getLine().' '.$th->getFile()); + } + } +} \ No newline at end of file diff --git a/app/ticket/controller/ConversationController.php b/app/ticket/controller/ConversationController.php new file mode 100644 index 0000000..6b90750 --- /dev/null +++ b/app/ticket/controller/ConversationController.php @@ -0,0 +1,64 @@ +input('ticket_id'); + if(empty($ticket_id)){ + return $this->error('ticket_id不能为空'); + } + $result = (new ConversationService())->list($request->all()); + return $this->success($this->page($result)); + } catch (\Throwable $e) { + return $this->error($e->getMessage().$e->getLine().$e->getFile()); + } + } + public function add(Request $request) + { + try { + $request->setPost('user_id', $request->user->uid); + return (new ConversationService())->add($request->all()) + ? $this->success() + : $this->error(); + } catch (\Throwable $th) { + return $this->error($th->getMessage()); + } + } + + /** + * 工单邮件补发 + * @param Request $request + * @return \support\Response + */ + public function sendEmail(Request $request) + { + try { + $res = (new ConversationService())->sendEmail($request->all()); + return $res + ? $this->success($res) + : $this->error(); + } catch (\Throwable $th) { + return $this->error($th->getMessage()); + } + } + + public function edit(Request $request) + { + try { + $request->setPost('user_id', $request->user->uid); + $result = (new ConversationService())->edit($request->all()); + return $result + ? $this->success($result) + : $this->error(); + } catch (\Throwable $th) { + return $this->error($th->getMessage().' '.$th->getLine().' '.$th->getFile()); + } + } +} \ No newline at end of file diff --git a/app/ticket/controller/TicketController.php b/app/ticket/controller/TicketController.php new file mode 100644 index 0000000..cb2290e --- /dev/null +++ b/app/ticket/controller/TicketController.php @@ -0,0 +1,88 @@ +list($request->all()); + return $this->success($this->page($result)); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 我的工单 + * @param Request $request + * @return \support\Response + */ + public function self(Request $request) + { + try { + $request->setGet('user_id',$request->user->uid); + $result = (new TicketService())->list($request->all()); + return $this->success($result); +// return $this->success($this->page($result)); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function add(Request $request) + { + try { + $request->setPost('user_id', $request->user->uid); + + return (new TicketService())->add($request->all()) + ? $this->success() + : $this->error(); + } catch (\Throwable $th) { + return $this->error($th->getMessage()); + } + } + + public function edit(Request $request) + { + try { + $request->setPost('user_id', $request->user->uid); + $result = (new TicketService())->edit($request->all()); + return $result + ? $this->success($result) + : $this->error(); + } catch (\Throwable $th) { + return $this->error($th->getMessage()); + } + } + public function status(Request $request) + { + try { + $request->setPost('user_id', $request->user->uid); + $result = (new TicketService())->status($request->all()); + return $result + ? $this->success($result) + : $this->error(); + } catch (\Throwable $th) { + return $this->error($th->getMessage()); + } + } + public function getStatus(Request $request) + { + try { + $request->setGet('user_id', $request->user->uid); + $result = (new TicketService())->getStatus($request->all()); + return $result + ? $this->success($result) + : $this->error(); + } catch (\Throwable $th) { + return $this->error($th->getMessage()); + } + } + +} diff --git a/app/ticket/logic/AttachmentLogic.php b/app/ticket/logic/AttachmentLogic.php new file mode 100644 index 0000000..b498f7e --- /dev/null +++ b/app/ticket/logic/AttachmentLogic.php @@ -0,0 +1,82 @@ +$v){ + $v['ticket_id'] = $ticket_id; + $v['user_id'] = $user_id; + $res = Attachment::create($v); + } + return $res; + } catch (\Throwable $e) { + Log::error('工单创建失败', [ + 'data' => $data, + 'error' => $e->getMessage() + ]); + throw new BusinessException('工单创建失败,请稍后重试', 500); + } + } + + public function edit(array $data,int $ticket_id,int $user_id) + { + try { + $attachmentsId = []; + foreach ($data as $k=>$v){ + $v['ticket_id'] = $ticket_id; + $v['user_id'] = $user_id; + if(!empty($v['id'])){ //修改附件 + $attachmentId = $v['id']; + $attachmentsId[] = $attachmentId; +// $v_res = Attachment::update($attachmentId, $v); + $v_res = Attachment::updateOrCreate(['id' => $v['id']],$v); + if (!$v_res) { + throw new BusinessException('附件保存失败'); + } + } else { //新增附件 + unset($v['id']); + $v_res = Attachment::create($v); + if (!$v_res) { + throw new BusinessException('附件创建失败'); + } + $vId = $v_res->id; + $attachmentsId[] = $vId; + } + } + + //删除本次没有出现的附件 + $res = Attachment::whereNotIn('id', $attachmentsId)->where('ticket_id',$ticket_id)->delete(); + return $res; + } catch (\Throwable $e) { + Log::error('工单编辑(附件)失败', [ + 'data' => $data, + 'error' => $e->getMessage() + ]); + throw new BusinessException('工单编辑失败,请稍后重试', 500); + } + } + + public static function generateSimpleTicketNo(): string + { + $date = date('Ymd'); // 日期 + $prefix = 'GD'; // 前缀 + $random = strtoupper(substr(md5(uniqid()), 0, 5)); // 5位随机字母数字 + + return $prefix . $date . '-' . $random; // 如 GD20240702-AB3X9Z + } +} \ No newline at end of file diff --git a/app/ticket/logic/CategorieLogic.php b/app/ticket/logic/CategorieLogic.php new file mode 100644 index 0000000..7eefaed --- /dev/null +++ b/app/ticket/logic/CategorieLogic.php @@ -0,0 +1,74 @@ +where($where)->paginate($size, ['*'], 'page', $current); + } + + public function getAll($params) + { + $where = []; + $where[] = ['status', '=', 1]; + return Categorie::with('translation')->where($where)->get(); + } + + public function find($id) + { + return Categorie::with(['translation' => function ($query) { + $query->where('locale', locale()); + }])->where('status', 1) + ->find($id); + } + + public function add(array $data) + { + try { + return Categorie::create($data); + } catch (\Throwable $e) { + Log::error('分类创建失败', [ + 'data' => $data, + 'error' => $e->getMessage() + ]); + throw new BusinessException('分类创建失败,请稍后重试', 500); + } + } + + public function edit(array $data) + { + try { + $categorie = Categorie::updateOrCreate(['id' => $data['id']], $data); + return $categorie; + } catch (\Throwable $e) { + Log::error('分类编辑失败', [ + 'data' => $data, + 'error' => $e->getMessage() + ]); + throw new BusinessException('分类编辑失败,请稍后重试', 500); + } + } + + public function delete(int $id) + { + try { + $categorie = Categorie::where(['id'=>$id])->delete(); + return $categorie; + } catch (\Throwable $e) { + Log::error('分类删除失败', [ + 'id' => $id, + 'error' => $e->getMessage() + ]); + throw new BusinessException('分类删除失败,请稍后重试', 500); + } + } +} \ No newline at end of file diff --git a/app/ticket/logic/ConversationLogic.php b/app/ticket/logic/ConversationLogic.php new file mode 100644 index 0000000..53a5a18 --- /dev/null +++ b/app/ticket/logic/ConversationLogic.php @@ -0,0 +1,60 @@ +paginate($size, ['*'], 'page', $current); + } + + public function add(array $data) + { + try { + return Conversation::create($data); + } catch (\Throwable $e) { + Log::error('会话创建失败', [ + 'data' => $data, + 'error' => $e->getMessage() + ]); + throw new BusinessException('会话创建失败,请稍后重试', 500); + } + } + public function edit(array $data) + { + try { + $conversation = Conversation::updateOrCreate(['id' => $data['id']], $data); + return $conversation; + } catch (\Throwable $e) { + Log::error('会话编辑失败', [ + 'data' => $data, + 'error' => $e->getMessage() + ]); + throw new BusinessException('会话编辑失败,请稍后重试', 500); + } + } + + /** + * 获取工单最新一条记录 + * @param array $data + * @return Conversation|\Illuminate\Database\Eloquent\Model|object|null + */ + public function firstByTicket(int $ticketId) + { + $conversation = Conversation::where('ticket_id',$ticketId) + ->orderBy('id','desc') + ->first(); + return $conversation; + } +} \ No newline at end of file diff --git a/app/ticket/logic/TicketLogic.php b/app/ticket/logic/TicketLogic.php new file mode 100644 index 0000000..540e7c5 --- /dev/null +++ b/app/ticket/logic/TicketLogic.php @@ -0,0 +1,77 @@ +with('categorie')->with('categorie.translation')->where($where)->orderBy('id','desc')->paginate($size, ['*'], 'page', $current); + } + public function add(array $data) + { + $data['status'] = 1; + $data['code'] = self::generateSimpleTicketNo(); + try { + return Ticket::create($data); + } catch (\Throwable $e) { + Log::error('工单创建失败', [ + 'data' => $data, + 'error' => $e->getMessage() + ]); + throw new BusinessException('工单创建失败,请稍后重试', 500); + } + } + public function edit(array $data) + { + try { + unset($data['code']); + $ticket = Ticket::updateOrCreate(['id' => $data['id']],$data); + return $ticket; + } catch (\Throwable $e) { + Log::error('工单编辑失败', [ + 'data' => $data, + 'error' => $e->getMessage() + ]); + throw new BusinessException('工单编辑失败,请稍后重试', 500); + } + } + + public function find(int $id) + { + $ticket = Ticket::find($id); + return $ticket; + } + + public static function generateSimpleTicketNo(): string + { + $date = date('Ymd'); // 日期 + $prefix = 'GD'; // 前缀 + $random = strtoupper(substr(md5(uniqid()), 0, 5)); // 5位随机字母数字 + + return $prefix . $date . '-' . $random; // 如 GD20240702-AB3X9Z + } +} \ No newline at end of file diff --git a/app/ticket/model/Attachment.php b/app/ticket/model/Attachment.php new file mode 100644 index 0000000..f5fc843 --- /dev/null +++ b/app/ticket/model/Attachment.php @@ -0,0 +1,66 @@ +belongsTo(Ticket::class, 'ticket_id', 'id'); + } +} diff --git a/app/ticket/model/Categorie.php b/app/ticket/model/Categorie.php new file mode 100644 index 0000000..610c693 --- /dev/null +++ b/app/ticket/model/Categorie.php @@ -0,0 +1,54 @@ + 'datetime:Y-m-d H:i:s', + 'created_at' => 'datetime:Y-m-d H:i:s', + ]; + protected $fillable = [ + 'name', + 'description', + ]; + + /** + * 单条翻译 + */ + public function translation() + { + return $this->hasOne(CategorieTranslation::class, 'categorie_id')->where('locale', locale()); + } + + /** + * 多条翻译 + */ + public function translations() + { + return $this->hasMany(CategorieTranslation::class, 'categorie_id'); + } + + public function ticket(){ + return $this->hasOne(Ticket::class, 'categorie_id', 'id'); + } +} \ No newline at end of file diff --git a/app/ticket/model/CategorieTranslation.php b/app/ticket/model/CategorieTranslation.php new file mode 100644 index 0000000..6660dea --- /dev/null +++ b/app/ticket/model/CategorieTranslation.php @@ -0,0 +1,35 @@ + 'datetime:Y-m-d H:i:s', + 'created_at' => 'datetime:Y-m-d H:i:s', + ]; + protected $fillable = [ + 'categorie_id', + 'locale', + 'name', + 'description', + ]; +} \ No newline at end of file diff --git a/app/ticket/model/Conversation.php b/app/ticket/model/Conversation.php new file mode 100644 index 0000000..641ff35 --- /dev/null +++ b/app/ticket/model/Conversation.php @@ -0,0 +1,30 @@ +created_at = time(); + $model->updated_at = time(); + }); + + static::updating(function ($model) { + $model->updated_at = time(); + }); + } + + /** + * 指定哪些字段自动转换为 Carbon 实例 + */ + public function getDates() + { + return []; + } + + protected $fillable = [ + 'code', + 'categorie_id', + 'content', + 'sort', + 'status', + 'user_id' + ]; + + public function attachments(){ + return $this->hasMany(Attachment::class, 'ticket_id', 'id'); + } + + public function categorie(){ + return $this->belongsTo(Categorie::class, 'categorie_id', 'id'); + } +} diff --git a/app/ticket/service/CategorieService.php b/app/ticket/service/CategorieService.php new file mode 100644 index 0000000..dedccd4 --- /dev/null +++ b/app/ticket/service/CategorieService.php @@ -0,0 +1,105 @@ +list($params); +// $value=$result[0]->toArray(); + foreach ($result as $key => &$value) { + $value['name'] = isset($value['translation']) ? $value['translation']['name'] : ''; + unset($value['translation']); + } + unset($value); + + return $result; + } + + public function getAll($params) + { + $result = (new CategorieLogic())->getAll($params); + foreach ($result as $key => &$value) { + $value['name'] = isset($value['translation']) ? $value['translation']['name'] : ''; + unset($value['translation']); + } + unset($value); + + return $result; + } + + public $translatFields = ['name']; + + public function add(array $data) + { + try { + Db::beginTransaction(); + $sourceLocale = locale(); + $traitsourceFields = Translat::extractSourceFields($data, $this->translatFields); + + $result = (new CategorieLogic())->add($data); + + (new TranslationService())->queueTranslations( + 'ticket_categorie', + $result->id, + $sourceLocale, + $traitsourceFields + ); + + Db::commit(); + return $result; + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + } + public function edit(array $data) + { + try { + Db::beginTransaction(); + $sourceLocale = locale(); + $traitsourceFields = Translat::extractSourceFields($data, $this->translatFields); + + $result = (new CategorieLogic())->edit($data); + +// (new TranslationService())->queueTranslations( +// 'ticket_categorie', +// $data['id'], +// $sourceLocale, +// $traitsourceFields +// ); + CategorieTranslation::updateOrCreate( + [ + 'categorie_id' => $data['id'], + 'locale' => $sourceLocale, + ], + $traitsourceFields + ); + Db::commit(); + return $result; + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + } + public function delete(int $id) + { + if(empty($id)){ + throw new BusinessException('id不能为空'); + } + try { + $result = (new CategorieLogic())->delete($id); + return $result; + } catch (\Throwable $e) { + throw $e; + } + } +} \ No newline at end of file diff --git a/app/ticket/service/ConversationService.php b/app/ticket/service/ConversationService.php new file mode 100644 index 0000000..972938e --- /dev/null +++ b/app/ticket/service/ConversationService.php @@ -0,0 +1,160 @@ +list($params); + + $status_fanyi = array_map(fn($v) => "{{{$v}}}", array_flip(Constants::TICKET_STATUS)); + $ossservice = new OssService(); + foreach ($result as $key => $value) { + $value=$value->toArray(); + $value['content'] = str_replace($status_fanyi,$ticket_status,$value['content']); + $value['attachment'] = empty($value['attachment'])?[]:unserialize($value['attachment']); + foreach ($value['attachment'] as $k => $attachment) { + $value['attachment'][$k]['storage_path_full'] = (!empty($attachment))?$ossservice->generatePrivateUrl($attachment['storage_path']):''; + } + $user = User::where('id', $value['user_id'])->first(); + $value['user_name'] = $user?->name; + $value['user_avatar'] = $user?->avatar; + $value['created_at'] = date('Y-m-d H:i:s', strtotime($value['created_at'])); + $value['updated_at'] = date('Y-m-d H:i:s', strtotime($value['updated_at'])); + $result[$key] = $value; + } + return $result; + } + + public function add(array $data) + { + try { + $data['attachment'] = serialize($data['attachment']); + if (empty($data['content'])) { + throw new BusinessException('消息内容不能为空'); + } + + $conversation = (new ConversationLogic())->firstByTicket($data['ticket_id']); + $ticket = (new TicketLogic())->find($data['ticket_id']); + $categorie= (new CategorieLogic())->find($ticket['categorie_id']); + $categorieName = isset($categorie['translation']) ? $categorie['translation']['name'] : ''; + + $userDefault = [1, 75, 78]; + $tiket_content = $ticket['content']; + if (empty($conversation)) { //没有沟通记录 + $title = '新工单提醒-'.$categorieName; + $notice_user_id = $userDefault; + }else{ + $title = '工单回复'; + $tiket = (new TicketLogic())->find($conversation['ticket_id']); + if($data['user_id'] != $tiket['user_id']){ + $notice_user_id = [$tiket['user_id']]; + }else{ + $conversationLast = Conversation::where('ticket_id', '=', $conversation['ticket_id']) + ->where('user_id', '!=', $data['user_id']) + ->orderBy('id', 'desc') + ->first(); + + $notice_user_id = (!empty($conversationLast['user_id']))? [$conversationLast['user_id']]:$userDefault; + } + $tiket_content = $tiket['content']; + + $status_fanyi = array_map(fn($v) => "{{{$v}}}", array_flip(Constants::TICKET_STATUS)); + $locale = locale(); + $ticket_status = $locale == 'zh'? Constants::TICKET_STATUS_ZH:Constants::TICKET_STATUS_EN; + $content = str_replace($status_fanyi,$ticket_status,$data['content']); + + $reply = $content?($content."\n".'工单内容:'):'' ; + } + + $notice = ['sender_id' => 0, //发送人ID 0默认系统 + 'sender_type' => 'system', //发送者类型 user admin system + 'title' => $title, //标题 + 'content' => ($reply??'') . $tiket_content, //内容 + 'scene' => 1, //场景 0系统公共 1系统通知 + 'recipient' => $notice_user_id, //接收人 空为全部 + 'push_method' => 0 //推送方式0系统推送1手动推送 + ]; + + // 异步推送 + \Webman\RedisQueue\Redis::send('notice', $notice); + + unset($data['categorie_id']); + unset($data['code']); + + return (new ConversationLogic())->add($data); + } catch (\Throwable $e) { + throw $e; + } + } + + public function sendEmail(array $data) + { + $conversation = (new ConversationLogic())->firstByTicket($data['ticket_id']); + $ticket = (new TicketLogic())->find($data['ticket_id']); + $categorie= (new CategorieLogic())->find($ticket['categorie_id']); + $categorieName = isset($categorie['translation']) ? $categorie['translation']['name'] : ''; + $notice = [ + 'code' => $ticket['code'], + 'category' => $categorieName, + 'content' => $ticket['content'], + 'reply' => '', + ]; + //$emailDefault = 'app@hnxinyouhe.com'; //公共邮箱 + //$emailDefault = 'support@hnxinyouhe.com'; //公共邮箱 + $emailDefault = env('ticket_email_default'); + if (empty($conversation)) { + $email = $emailDefault; + }else{ + $tiket = (new TicketLogic())->find($conversation['ticket_id']); + + if($conversation['user_id'] != $tiket['user_id']){ + $email = User::where('id',$tiket['user_id'])->value('email'); + }else{ + $conversationLast = Conversation::where('ticket_id', '=', $conversation['ticket_id']) + ->where('user_id', '!=', $conversation['user_id']) + ->orderBy('id', 'desc') + ->first(); + $email = (!empty($conversationLast['user_id']))?User::where('id',$conversationLast['user_id'])->value('email',$emailDefault):$emailDefault; + } + + $notice['content'] = $tiket['content']; +// $notice['reply'] = '
  • 最新回复:' . $conversation['content'] . '
  • '; + $notice['reply'] = '
  • 最新回复:' . '卡罗拉、极石01、比亚迪唐,宋Plus、丰田荣放、捷途T2、极氪7x、(阿联酋, 亚美尼亚, 阿塞拜疆, 白俄罗斯, 哥斯达黎加, 埃塞俄比亚, 格鲁吉亚, 加纳, 吉尔吉斯斯坦, 蒙古, 巴拉圭, 沙特阿拉伯, 塔吉克斯坦, 土库曼斯坦, 乌兹别克斯坦,委内瑞拉)起亚亦跑(阿尔及利亚),基本上每个车都推中亚中东南美非洲' . '
  • '; + } + + \app\common\utils\email\MailSend::send($email, '【XYH】新的工单提醒', replaceTpl($notice, 'work_order')); + return $email; + } + + public function edit(array $data) + { + try { + Db::beginTransaction(); + $data['attachment'] = serialize($data['attachment']); + $result = (new ConversationLogic())->edit($data); + Db::commit(); + return $result; + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + } +} \ No newline at end of file diff --git a/app/ticket/service/TicketService.php b/app/ticket/service/TicketService.php new file mode 100644 index 0000000..ce772df --- /dev/null +++ b/app/ticket/service/TicketService.php @@ -0,0 +1,167 @@ +list($params); + foreach ($res as $k => &$v) { + $name = isset($v['categorie']['translation']) ? $v['categorie']['translation']['name'] : ''; + if(isset($res[$k]['categorie'])){ + $res[$k]['categorie']['name'] = $name; + } + if (isset($v['categorie']['translation'])){ + unset($res[$k]['categorie']['translation']); + } + foreach ($v['attachments'] as &$attachments) { + $attachments['storage_path_full'] = (new \app\common\service\OssService())->generatePrivateUrl($attachments['storage_path']); + } + + $user = User::where('id', $v['user_id'])->first(); + $res[$k]['user_name'] = $user?->name; + $res[$k]['user_avatar'] = $user?->avatar; + $res[$k]['created_at'] = date('Y-m-d H:i:s', $v['created_at']); + $res[$k]['updated_at'] = date('Y-m-d H:i:s', $v['updated_at']); + } + unset($v); + unset($attachments); + + return $res; + } + + public function add(array $data) + { + try { + $attachment = $data['attachment']??[]; + $logic = new TicketLogic(); + $data['code'] = $logic::generateSimpleTicketNo(); + $result = $logic->add($data); + if(!empty($attachment)){ + (new AttachmentLogic())->adds($attachment, $result->id, $data['user_id']); + } + + $cleanContent = trim(preg_replace('/\s+/', ' ', strip_tags($data['content'] ?? ''))); + $conversation = [ + 'ticket_id' => $result->id, + 'categorie_id' => $data['categorie_id'], + 'code' => $data['code'], + 'content' => $cleanContent, + 'user_id' => $data['user_id'], + 'attachment' => $data['attachment'], + ]; + (new ConversationService())->add($conversation); + + return $result; + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + } + + public function edit(array $data) + { + try { + Db::beginTransaction(); + $logic = new TicketLogic(); + $result = $logic->find($data['id']); + + if(empty($result)){ + throw new BusinessException('工单不存在'); + } + if($result['user_id'] != $data['user_id']){ + throw new BusinessException('只能编辑自己的工单'); + } + if($result['status'] != 1){ + throw new BusinessException('只能修改待受理工单'); + } + + $attachment = $data['attachment']??[]; + $result = $logic->edit($data); + + if(!empty($attachment)){ + (new AttachmentLogic())->edit($attachment, $result->id, $data['user_id']); + } + Db::commit(); + return $result; + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + } + + public function status(array $data) + { + try { + Db::beginTransaction(); + $msg = $data['msg']??[]; + $ticket = (new TicketLogic())->find($data['id']); + if(empty($ticket)){ + throw new BusinessException('工单不存在'); + } + if($data['status'] <= $ticket->status){ + throw new BusinessException(trans('status_not_modified')); + } + +// if(!in_array($ticket->status, [Constants::TICKET_STATUS['PENDING'], Constants::TICKET_STATUS['CONFIRM']]) +// && empty($msg)){ +// throw new BusinessException('提交失败'); +// } + + $ticket->status = $data['status']; + $result = $ticket->save(); + + if(!empty($msg)){ + $conversation = [ + 'ticket_id' => $ticket->id, + 'user_id' => $data['user_id'], + 'content' => '状态更新为{{'.array_search($data['status'],Constants::TICKET_STATUS).'}}:'.$msg + ]; + $res = (new ConversationLogic())->add($conversation); + } + + Db::commit(); + return $result; + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + } + + public function getStatus(array $data) + { + $locale = locale(); + $ticket_status = $locale == 'zh'? Constants::TICKET_STATUS_ZH:Constants::TICKET_STATUS_EN; + + $status = []; + foreach ($ticket_status as $key => $value) { + $status[] = ['value' => $key, 'text' => $value]; + } + + if (empty($data['id'])) { + return $status; + } + + $res = (new TicketLogic())->find($data['id']); + if(!empty($res) && $res->user_id == $data['user_id']){ + $status = [ + ['value' => Constants::TICKET_STATUS['PENDING'], 'text' => $ticket_status[Constants::TICKET_STATUS['PENDING']]], + ['value' => Constants::TICKET_STATUS['REJECTED'], 'text' => $ticket_status[Constants::TICKET_STATUS['REJECTED']]], + ]; + } + + return $status; + } +} diff --git a/app/view/index/qrcode.html b/app/view/index/qrcode.html new file mode 100644 index 0000000..97c2a7c --- /dev/null +++ b/app/view/index/qrcode.html @@ -0,0 +1,46 @@ + + + + + 飞书扫码登录示例 + + + + + +
    + + + + diff --git a/app/view/index/view.html b/app/view/index/view.html new file mode 100644 index 0000000..67ebb26 --- /dev/null +++ b/app/view/index/view.html @@ -0,0 +1,14 @@ + + + + + + + + webman + + + +hello + + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..fc33191 --- /dev/null +++ b/composer.json @@ -0,0 +1,74 @@ +{ + "name": "workerman/webman", + "type": "project", + "keywords": [ + "high performance", + "http service" + ], + "homepage": "https://www.workerman.net", + "license": "MIT", + "description": "High performance HTTP Service Framework.", + "authors": [ + { + "name": "walkor", + "email": "walkor@workerman.net", + "homepage": "https://www.workerman.net", + "role": "Developer" + } + ], + "support": { + "email": "walkor@workerman.net", + "issues": "https://github.com/walkor/webman/issues", + "forum": "https://wenda.workerman.net/", + "wiki": "https://workerman.net/doc/webman", + "source": "https://github.com/walkor/webman" + }, + "require": { + "php": ">=8.1", + "workerman/webman-framework": "^2.1", + "monolog/monolog": "^2.0", + "webman/database": "^2.1", + "illuminate/pagination": "^12.18", + "illuminate/events": "^12.18", + "symfony/var-dumper": "^7.3", + "webman/redis": "^2.1", + "webman/cache": "^2.1", + "webman/console": "^2.1", + "vlucas/phpdotenv": "^5.6", + "firebase/php-jwt": "^6.11", + "topthink/think-validate": "^3.0", + "shopwwi/webman-filesystem": "1.1", + "shopwwi/filesystem-oss": "^1.0", + "webman/redis-queue": "^2.1", + "symfony/translation": "^7.3", + "symfony/filesystem": "^7.3", + "workerman/crontab": "^1.0", + "phpoffice/phpspreadsheet": "^4.5", + "phpmailer/phpmailer": "^6.10", + "webman/gateway-worker": "^1.0" + }, + "suggest": { + "ext-event": "For better performance. " + }, + "autoload": { + "psr-4": { + "": "./", + "app\\": "./app", + "App\\": "./app", + "app\\View\\Components\\": "./app/view/components" + } + }, + "scripts": { + "post-package-install": [ + "support\\Plugin::install" + ], + "post-package-update": [ + "support\\Plugin::install" + ], + "pre-package-uninstall": [ + "support\\Plugin::uninstall" + ] + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..604b04e --- /dev/null +++ b/config/app.php @@ -0,0 +1,26 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use support\Request; + +return [ + 'debug' => getenv('DEBUG'), + 'error_reporting' => E_ALL, + 'default_timezone' => 'Asia/Shanghai', + 'request_class' => Request::class, + 'public_path' => base_path() . DIRECTORY_SEPARATOR . 'public', + 'runtime_path' => base_path(false) . DIRECTORY_SEPARATOR . 'runtime', + 'controller_suffix' => 'Controller', + 'controller_reuse' => false, +]; diff --git a/config/autoload.php b/config/autoload.php new file mode 100644 index 0000000..69a8135 --- /dev/null +++ b/config/autoload.php @@ -0,0 +1,21 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + 'files' => [ + base_path() . '/app/functions.php', + base_path() . '/support/Request.php', + base_path() . '/support/Response.php', + ] +]; diff --git a/config/bootstrap.php b/config/bootstrap.php new file mode 100644 index 0000000..96699de --- /dev/null +++ b/config/bootstrap.php @@ -0,0 +1,20 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + support\bootstrap\Session::class, + + // 日志 + app\bootstrap\Audit::class, +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..74775dc --- /dev/null +++ b/config/cache.php @@ -0,0 +1,31 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + 'default' => 'file', + 'stores' => [ + 'file' => [ + 'driver' => 'file', + 'path' => runtime_path('cache') + ], + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default' + ], + 'array' => [ + 'driver' => 'array' + ] + ] +]; \ No newline at end of file diff --git a/config/constants/Constants.php b/config/constants/Constants.php new file mode 100644 index 0000000..fa0dd81 --- /dev/null +++ b/config/constants/Constants.php @@ -0,0 +1,137 @@ + 1, // 待受理 + 'PROCESS' => 2, //处理中 + 'CONFIRM' => 3, // 待确认 + 'FINISH' => 4, // 已完成 + 'REJECTED' => 5, // 已驳回 + ]; + + public const TICKET_STATUS_ZH = [ + self::TICKET_STATUS['PENDING'] => '待受理', + self::TICKET_STATUS['PROCESS'] => '处理中', + self::TICKET_STATUS['CONFIRM'] => '待确认', + self::TICKET_STATUS['FINISH'] => '已完成', + self::TICKET_STATUS['REJECTED'] => '关闭', + ]; + public const TICKET_STATUS_EN = [ + self::TICKET_STATUS['PENDING'] => 'Pending acceptance', + self::TICKET_STATUS['PROCESS'] => 'Processing', + self::TICKET_STATUS['CONFIRM'] => 'To be confirmed', + self::TICKET_STATUS['FINISH'] => 'Completed', + self::TICKET_STATUS['REJECTED'] => 'close', + ]; + public const TICKET_STATUS_RU = [ + self::TICKET_STATUS['PENDING'] => 'Подлежит рассмотрению', + self::TICKET_STATUS['PROCESS'] => 'В процессе обработки', + self::TICKET_STATUS['CONFIRM'] => 'Подлежит подтверждению', + self::TICKET_STATUS['FINISH'] => 'Завершено', + self::TICKET_STATUS['REJECTED'] => 'Закрыть', + ]; + + //客户分级 USTOMER_GRADE + + public const GRADE = [ + 'INITIAL' => 0, // 初步接触 + 'COMMUNICATE' => 1, // 需求沟通 + 'PLAN' => 2, // 方案推进 + 'DEAL' => 3, // 成交阶段 + 'SALES' => 4, // 售后阶段 + 'LOSS' => 5, // 停滞或流失 + ]; + + public const GRADE_ZH = [ + self::GRADE['INITIAL'] => '初步接触', + self::GRADE['COMMUNICATE'] => '需求沟通', + self::GRADE['PLAN'] => '方案推进', + self::GRADE['DEAL'] => '成交阶段', + self::GRADE['SALES'] => '售后阶段', + self::GRADE['LOSS'] => '停滞或流失', + ]; + public const GRADE_EN = [ + self::GRADE['INITIAL'] => 'Initial contact', + self::GRADE['COMMUNICATE'] => 'Demand Chat', + self::GRADE['PLAN'] => 'promotion of the plan', + self::GRADE['DEAL'] => 'Transaction Stage', + self::GRADE['SALES'] => 'After Sales', + self::GRADE['LOSS'] => 'Stagnation or loss', + ]; + public const GRADE_RU = [ + self::GRADE['INITIAL'] => 'Первоначальные контакты', + self::GRADE['COMMUNICATE'] => 'Потребности в общении', + self::GRADE['PLAN'] => 'Продвижение программы', + self::GRADE['DEAL'] => 'Этап сделки', + self::GRADE['SALES'] => 'Послепродажный этап', + self::GRADE['LOSS'] => 'Застой или потеря', + ]; + + //客户重要性 + public const CUSTOMER_MAGNITUDE_INITIAL = 1; // 金牌商机 + public const CUSTOMER_MAGNITUDE_COMMUNICATE = 2; // 银牌商机 + public const CUSTOMER_MAGNITUDE_PLAN = 3; // 铜牌商机 + + public const MAGNITUDE = [ + self::CUSTOMER_MAGNITUDE_INITIAL => '💛金牌商机', + self::CUSTOMER_MAGNITUDE_COMMUNICATE => '🤍银牌商机', + self::CUSTOMER_MAGNITUDE_PLAN => '🧡铜牌商机', + ]; + public const MAGNITUDE_EN = [ + self::CUSTOMER_MAGNITUDE_INITIAL => 'Gold Medal', + self::CUSTOMER_MAGNITUDE_COMMUNICATE => 'Silver Medal', + self::CUSTOMER_MAGNITUDE_PLAN => 'Bronze Medal', + ]; + + public const MAGNITUDE_RU = [ + self::CUSTOMER_MAGNITUDE_INITIAL => 'Золотой бизнес', + self::CUSTOMER_MAGNITUDE_COMMUNICATE => 'Серебряный бизнес', + self::CUSTOMER_MAGNITUDE_PLAN => 'Бронзовый бизнес.', + ]; + + + //跟进类型 + public const CUSTOMER_FOLLOW_VISIT = 0; // 线下拜访 + public const CUSTOMER_FOLLOW_UP = 1; // 线上跟进 + + public const FOLLOW = [ + self::CUSTOMER_FOLLOW_VISIT => '线下拜访', + self::CUSTOMER_FOLLOW_UP => '线上跟进', + ]; + public const FOLLOW_EN = [ + self::CUSTOMER_FOLLOW_VISIT => 'Visit', + self::CUSTOMER_FOLLOW_UP => 'On line', + ]; + + //跟进类型 + public const CUSTOMER_SEX_UNKNOWN = 0; // 未知 + public const CUSTOMER_SEX_MAN = 1; // 男 + public const CUSTOMER_SEX_WOMAN = 2; // 女 + + public const SEX = [ + self::CUSTOMER_SEX_UNKNOWN => '未知', + self::CUSTOMER_SEX_MAN => '男', + self::CUSTOMER_SEX_WOMAN => '女', + ]; + public const SEX_EN = [ + self::CUSTOMER_SEX_UNKNOWN => 'unknown', + self::CUSTOMER_SEX_MAN => 'man', + self::CUSTOMER_SEX_WOMAN => 'woman', + ]; + + public const SEX_RU = [ + self::CUSTOMER_SEX_UNKNOWN => 'неизвестный', + self::CUSTOMER_SEX_MAN => 'мужской', + self::CUSTOMER_SEX_WOMAN => 'женский', + ]; + + public const SIGN = [ + ['id' => 'xinyouhe', 'name' => '新友合'], + ['id' => 'mbaf', 'name' => 'MAGIC BRIDGE AUTO FZE'] + ]; + + public const LANGUAGE = ['en', 'zh_CN','ru']; +} \ No newline at end of file diff --git a/config/container.php b/config/container.php new file mode 100644 index 0000000..106b7b4 --- /dev/null +++ b/config/container.php @@ -0,0 +1,15 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return new Webman\Container; \ No newline at end of file diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..93308ad --- /dev/null +++ b/config/database.php @@ -0,0 +1,29 @@ + 'mysql', + 'connections' => [ + 'mysql' => [ + 'driver' => 'mysql', + 'host' => getenv('DB_HOST'), + 'port' => getenv('DB_PORT'), + 'database' => getenv('DB_NAME'), + 'username' => getenv('DB_USER'), + 'password' => getenv('DB_PASSWORD'), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_general_ci', + 'prefix' => getenv('DB_PREFIX'), + 'strict' => true, + 'engine' => null, + 'options' => [ + PDO::ATTR_EMULATE_PREPARES => false, // Must be false for Swoole and Swow drivers. + ], + 'pool' => [ + 'max_connections' => 5, + 'min_connections' => 1, + 'wait_timeout' => 3, + 'idle_timeout' => 60, + 'heartbeat_interval' => 50, + ], + ], + ], +]; \ No newline at end of file diff --git a/config/dependence.php b/config/dependence.php new file mode 100644 index 0000000..8e964ed --- /dev/null +++ b/config/dependence.php @@ -0,0 +1,15 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return []; \ No newline at end of file diff --git a/config/exception.php b/config/exception.php new file mode 100644 index 0000000..f2aede3 --- /dev/null +++ b/config/exception.php @@ -0,0 +1,17 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + '' => support\exception\Handler::class, +]; \ No newline at end of file diff --git a/config/imap.php b/config/imap.php new file mode 100644 index 0000000..de9d8f1 --- /dev/null +++ b/config/imap.php @@ -0,0 +1,52 @@ + '{imap.feishu.cn:993/imap/ssl}INBOX', + 'username' => 'info@borderlesscar.com', + 'password' => 'ErkhUA629aocm7Bc', + // 最多拉取多少封邮件 + 'limit' => 5, + 'per_page' => 20, + 'batch_size'=> 100, // 可选:CLI 不传 -p 时使用 + + // stmp + 'smtp' => [ + 'host' => 'smtp.feishu.cn', + 'port' => 465, + 'username' => 'info@borderlesscar.com', + 'password' => 'ErkhUA629aocm7Bc', + 'encryption' => 'ssl', + 'from_address' => '', + 'from_name' => '' + ], + + 'smtp_xyh' => [ + 'host' => 'smtp.feishu.cn', + 'port' => 465, + 'username' => 'app@hnxinyouhe.com', + 'password' => 'akyIUFJ6ihGGjI5h', + 'encryption' => 'ssl', + 'from_address' => 'app@hnxinyouhe.com', + 'from_name' => '【XYH】' + ], +]; + +// docker run \ +// --name l2tp-vpn-server \ +// --env VPN_IPSEC_PSK='Xyh123456' \ +// --env VPN_USER='xinyouhe' \ +// --env VPN_PASSWORD='Xyh.2025' \ +// --restart=always \ +// --privileged \ +// -p 500:500/udp \ +// -p 4500:4500/udp \ +// -p 1701:1701/udp \ +// -d hwdsl2/ipsec-vpn-server + + +// 拉取邮件的脚本顺序: +// php webman emails:fetch:uid +// php webman emails:fetch +// php webman parse:emails \ No newline at end of file diff --git a/config/jwt.php b/config/jwt.php new file mode 100644 index 0000000..03f42fc --- /dev/null +++ b/config/jwt.php @@ -0,0 +1,9 @@ + getenv('JWT_SECRET_KEY') ?: 'default_secret_key', + 'jwt_algorithm' => getenv('JWT_ALGORITHM') ?: 'HS256', + 'jwt_issuer' => getenv('JWT_ISSUER') ?: 'domain.com', + 'jwt_expiration' => (int)(getenv('JWT_EXPIRATION') ?: 86400), + 'jwt_refresh_token_expiration' => (int)(getenv('JWT_REFRESH_TOKEN_EXPIRATION') ?: 2592000) +]; \ No newline at end of file diff --git a/config/log.php b/config/log.php new file mode 100644 index 0000000..7f05de5 --- /dev/null +++ b/config/log.php @@ -0,0 +1,32 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + 'default' => [ + 'handlers' => [ + [ + 'class' => Monolog\Handler\RotatingFileHandler::class, + 'constructor' => [ + runtime_path() . '/logs/webman.log', + 7, //$maxFiles + Monolog\Logger::DEBUG, + ], + 'formatter' => [ + 'class' => Monolog\Formatter\LineFormatter::class, + 'constructor' => [null, 'Y-m-d H:i:s', true], + ], + ] + ], + ], +]; diff --git a/config/middleware.php b/config/middleware.php new file mode 100644 index 0000000..ec8d90a --- /dev/null +++ b/config/middleware.php @@ -0,0 +1,23 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + + +return [ + '' => [ + \app\middleware\AccessControl::class, + \app\middleware\BasicData::class, + \app\middleware\AuthCheck::class, + \app\middleware\Audit::class, + ] +]; \ No newline at end of file diff --git a/config/oauth.php b/config/oauth.php new file mode 100644 index 0000000..e414043 --- /dev/null +++ b/config/oauth.php @@ -0,0 +1,9 @@ + __DIR__ . '/private.key', + 'public_key' => __DIR__ . '/public.key', + 'encrypt_key' => env('OAUTH_ENCRYPT_KEY', 'base64:xxxxxxxx'), + 'access_token_expire' => 7200, + 'refresh_token_expire' => 1209600, +]; \ No newline at end of file diff --git a/config/oss.php b/config/oss.php new file mode 100644 index 0000000..bb03fc7 --- /dev/null +++ b/config/oss.php @@ -0,0 +1,10 @@ + env('OSS_ACCESSID'), + 'oss_accesssecret' => env('OSS_ACCESSSECRET'), + 'oss_bucket' => env('OSS_BUCKET'), + 'oss_endpoint' => env('OSS_ENDPOINT'), + 'oss_static_url' => env('OSS_STATIC_URL'), + 'oss_main_dir' => env('OSS_MAIN_DIR') +]; \ No newline at end of file diff --git a/config/plugin/shopwwi/filesystem/app.php b/config/plugin/shopwwi/filesystem/app.php new file mode 100644 index 0000000..f1c74b7 --- /dev/null +++ b/config/plugin/shopwwi/filesystem/app.php @@ -0,0 +1,116 @@ + true, + 'default' => 'local', + 'max_size' => 1024 * 1024 * 10, //单个文件大小10M + 'ext_yes' => [], //允许上传文件类型 为空则为允许所有 + 'ext_no' => [], // 不允许上传文件类型 为空则不限制 + 'storage' => [ + 'public' => [ + 'driver' => \Shopwwi\WebmanFilesystem\Adapter\LocalAdapterFactory::class, + 'root' => public_path(), + 'url' => '//127.0.0.1:8787' // 静态文件访问域名 + ], + 'local' => [ + 'driver' => \Shopwwi\WebmanFilesystem\Adapter\LocalAdapterFactory::class, + 'root' => runtime_path(), + 'url' => '//127.0.0.1:8787' // 静态文件访问域名 + ], + 'ftp' => [ + 'driver' => \Shopwwi\WebmanFilesystem\Adapter\FtpAdapterFactory::class, + 'host' => 'ftp.example.com', + 'username' => 'username', + 'password' => 'password', + 'url' => '' // 静态文件访问域名 + // 'port' => 21, + // 'root' => '/path/to/root', + // 'passive' => true, + // 'ssl' => true, + // 'timeout' => 30, + // 'ignorePassiveAddress' => false, + // 'timestampsOnUnixListingsEnabled' => true, + ], + 'memory' => [ + 'driver' => \Shopwwi\WebmanFilesystem\Adapter\MemoryAdapterFactory::class, + ], + 's3' => [ + 'driver' => \Shopwwi\WebmanFilesystem\Adapter\S3AdapterFactory::class, + 'credentials' => [ + 'key' => 'S3_KEY', + 'secret' => 'S3_SECRET', + ], + 'region' => 'S3_REGION', + 'version' => 'latest', + 'bucket_endpoint' => false, + 'use_path_style_endpoint' => false, + 'endpoint' => 'S3_ENDPOINT', + 'bucket_name' => 'S3_BUCKET', + 'url' => '' // 静态文件访问域名 + ], + 'minio' => [ + 'driver' => \Shopwwi\WebmanFilesystem\Adapter\S3AdapterFactory::class, + 'credentials' => [ + 'key' => 'S3_KEY', + 'secret' => 'S3_SECRET', + ], + 'region' => 'S3_REGION', + 'version' => 'latest', + 'bucket_endpoint' => false, + 'use_path_style_endpoint' => true, + 'endpoint' => 'S3_ENDPOINT', + 'bucket_name' => 'S3_BUCKET', + 'url' => '' // 静态文件访问域名 + ], + 'oss' => [ + 'driver' => \Shopwwi\WebmanFilesystem\Adapter\AliyunOssAdapterFactory::class, + 'accessId' => getenv('OSS_ACCESSID'), + 'accessSecret' => getenv('OSS_ACCESSSECRET'), + 'bucket' => getenv('OSS_BUCKET'), + 'endpoint' => getenv('OSS_ENDPOINT'), + 'url' => getenv('OSS_STATIC_URL'), // 静态文件访问域名 + // 'timeout' => 3600, + // 'connectTimeout' => 10, + // 'isCName' => false, + // 'token' => null, + // 'proxy' => null, + ], + 'qiniu' => [ + 'driver' => \Shopwwi\WebmanFilesystem\Adapter\QiniuAdapterFactory::class, + 'accessKey' => 'QINIU_ACCESS_KEY', + 'secretKey' => 'QINIU_SECRET_KEY', + 'bucket' => 'QINIU_BUCKET', + 'domain' => 'QINBIU_DOMAIN', + 'url' => '' // 静态文件访问域名 + ], + 'cos' => [ + 'driver' => \Shopwwi\WebmanFilesystem\Adapter\CosAdapterFactory::class, + 'region' => 'COS_REGION', + 'app_id' => 'COS_APPID', + 'secret_id' => 'COS_SECRET_ID', + 'secret_key' => 'COS_SECRET_KEY', + // 可选,如果 bucket 为私有访问请打开此项 + // 'signed_url' => false, + 'bucket' => 'COS_BUCKET', + 'read_from_cdn' => false, + 'url' => '' // 静态文件访问域名 + // 'timeout' => 60, + // 'connect_timeout' => 60, + // 'cdn' => '', + // 'scheme' => 'https', + ], + ], +]; \ No newline at end of file diff --git a/config/plugin/webman/console/app.php b/config/plugin/webman/console/app.php new file mode 100644 index 0000000..074e986 --- /dev/null +++ b/config/plugin/webman/console/app.php @@ -0,0 +1,24 @@ + true, + + 'build_dir' => BASE_PATH . DIRECTORY_SEPARATOR . 'build', + + 'phar_filename' => 'webman.phar', + + 'bin_filename' => 'webman.bin', + + 'signature_algorithm'=> Phar::SHA256, //set the signature algorithm for a phar and apply it. The signature algorithm must be one of Phar::MD5, Phar::SHA1, Phar::SHA256, Phar::SHA512, or Phar::OPENSSL. + + 'private_key_file' => '', // The file path for certificate or OpenSSL private key file. + + 'exclude_pattern' => '#^(?!.*(composer.json|/.github/|/.idea/|/.git/|/.setting/|/runtime/|/vendor-bin/|/build/|/vendor/webman/admin/))(.*)$#', + + 'exclude_files' => [ + '.env', 'LICENSE', 'composer.json', 'composer.lock', 'start.php', 'webman.phar', 'webman.bin' + ], + + 'custom_ini' => ' +memory_limit = 256M + ', +]; diff --git a/config/plugin/webman/gateway-worker/app.php b/config/plugin/webman/gateway-worker/app.php new file mode 100644 index 0000000..8f9c426 --- /dev/null +++ b/config/plugin/webman/gateway-worker/app.php @@ -0,0 +1,4 @@ + true, +]; \ No newline at end of file diff --git a/config/plugin/webman/gateway-worker/process.php b/config/plugin/webman/gateway-worker/process.php new file mode 100644 index 0000000..16180d9 --- /dev/null +++ b/config/plugin/webman/gateway-worker/process.php @@ -0,0 +1,39 @@ + [ + 'handler' => Gateway::class, + 'listen' => 'websocket://0.0.0.0:' . getenv('GATEWAY_LISTEN'), + 'count' => 3, + 'reloadable' => false, + 'constructor' => ['config' => [ + 'lanIp' => '127.0.0.1', + 'startPort' => 2300, + 'pingInterval' => 55, + 'pingNotResponseLimit' => 1, + 'pingData' => '', + 'registerAddress' => '127.0.0.1:1236', + 'onConnect' => function(){}, + ]] + ], + 'worker' => [ + 'handler' => BusinessWorker::class, + 'count' => 2, + 'constructor' => ['config' => [ + 'eventHandler' => plugin\webman\gateway\Events::class, + 'name' => 'ChatBusinessWorker', + 'registerAddress' => '127.0.0.1:1236', + ]] + ], + 'register' => [ + 'handler' => Register::class, + 'listen' => 'text://127.0.0.1:1236', + 'count' => 1, // Must be 1 + 'reloadable' => false, + 'constructor' => [] + ], +]; diff --git a/config/plugin/webman/log/app.php b/config/plugin/webman/log/app.php new file mode 100644 index 0000000..6217553 --- /dev/null +++ b/config/plugin/webman/log/app.php @@ -0,0 +1,19 @@ + true, + 'exception' => [ + // 是否记录异常到日志 + 'enable' => true, + // 不会记录到日志的异常类 + 'dontReport' => [ + support\exception\BusinessException::class + ] + ], + 'dontReport' => [ + 'app' => [], + 'controller' => [], + 'action' => [], + 'path' => [] + ], + 'channel' => 'default' // 日志通道(在config/log.php里配置,默认是default) +]; diff --git a/config/plugin/webman/log/middleware.php b/config/plugin/webman/log/middleware.php new file mode 100644 index 0000000..7c7b0a9 --- /dev/null +++ b/config/plugin/webman/log/middleware.php @@ -0,0 +1,21 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use Webman\Log\Middleware; + +return [ + '@' => [ + Middleware::class + ] +]; diff --git a/config/plugin/webman/redis-queue/app.php b/config/plugin/webman/redis-queue/app.php new file mode 100644 index 0000000..8f9c426 --- /dev/null +++ b/config/plugin/webman/redis-queue/app.php @@ -0,0 +1,4 @@ + true, +]; \ No newline at end of file diff --git a/config/plugin/webman/redis-queue/command.php b/config/plugin/webman/redis-queue/command.php new file mode 100644 index 0000000..8bfe2a1 --- /dev/null +++ b/config/plugin/webman/redis-queue/command.php @@ -0,0 +1,7 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + 'default' => [ + 'handlers' => [ + [ + 'class' => Monolog\Handler\RotatingFileHandler::class, + 'constructor' => [ + runtime_path() . '/logs/redis-queue/queue.log', + 7, //$maxFiles + Monolog\Logger::DEBUG, + ], + 'formatter' => [ + 'class' => Monolog\Formatter\LineFormatter::class, + 'constructor' => [null, 'Y-m-d H:i:s', true], + ], + ] + ], + ] +]; diff --git a/config/plugin/webman/redis-queue/process.php b/config/plugin/webman/redis-queue/process.php new file mode 100644 index 0000000..8474aee --- /dev/null +++ b/config/plugin/webman/redis-queue/process.php @@ -0,0 +1,12 @@ + [ + 'handler' => Webman\RedisQueue\Process\Consumer::class, + 'count' => getenv('REDIS_QUEUE_PROCESS_COUNT'), // 可以设置多进程同时消费 + 'constructor' => [ + // 消费者类目录 + 'consumer_dir' => app_path() . '/queue/redis', + 'consumer_dir' => app_path() . getenv('REDIS_QUEUE_CONSUMER_DIR') + ] + ] +]; \ No newline at end of file diff --git a/config/plugin/webman/redis-queue/redis.php b/config/plugin/webman/redis-queue/redis.php new file mode 100644 index 0000000..420914d --- /dev/null +++ b/config/plugin/webman/redis-queue/redis.php @@ -0,0 +1,22 @@ + [ +// 'host' => 'redis://127.0.0.1:6379', + 'host' => getenv('REDIS_QUEUE_HOST'), + 'options' => [ + 'auth' => getenv('REDIS_QUEUE_AUTH'), // 密码,字符串类型,可选参数 + 'db' => getenv('REDIS_QUEUE_DB'), // 数据库 + 'prefix' => getenv('REDIS_QUEUE_PREFIX'), // key 前缀 + 'max_attempts' => getenv('REDIS_QUEUE_MAX_ATTEMPTS'), // 消费失败后,重试次数 + 'retry_seconds' => getenv('REDIS_QUEUE_RETRY_SECONDS'), // 重试间隔,单位秒 + ], + // Connection pool, supports only Swoole or Swow drivers. + 'pool' => [ + 'max_connections' => 5, + 'min_connections' => 1, + 'wait_timeout' => 3, + 'idle_timeout' => 60, + 'heartbeat_interval' => 50, + ] + ], +]; diff --git a/config/process.php b/config/process.php new file mode 100644 index 0000000..8022ef3 --- /dev/null +++ b/config/process.php @@ -0,0 +1,72 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use support\Log; +use support\Request; +use app\process\Http; + +global $argv; + +return [ + 'webman' => [ + 'handler' => Http::class, + 'listen' => 'http://0.0.0.0:'.getenv('PROCESS_LISTEN'), + // 'count' => cpu_count() * 4, + 'count' => getenv('PROCESS_COUNT'), + 'user' => '', + 'group' => '', + 'reusePort' => false, + 'eventLoop' => '', + 'context' => [], + 'constructor' => [ + 'requestClass' => Request::class, + 'logger' => Log::channel('default'), + 'appPath' => app_path(), + 'publicPath' => public_path() + ] + ], + // File update detection and automatic reload + 'monitor' => [ + 'handler' => app\process\Monitor::class, + 'reloadable' => false, + 'constructor' => [ + // Monitor these directories + 'monitorDir' => array_merge([ + app_path(), + config_path(), + base_path() . '/process', + base_path() . '/support', + base_path() . '/resource', + base_path() . '/.env', + ], glob(base_path() . '/plugin/*/app'), glob(base_path() . '/plugin/*/config'), glob(base_path() . '/plugin/*/api')), + // Files with these suffixes will be monitored + 'monitorExtensions' => [ + 'php', 'html', 'htm', 'env' + ], + 'options' => [ + 'enable_file_monitor' => !in_array('-d', $argv) && DIRECTORY_SEPARATOR === '/', + 'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/', + ] + ] + ], + 'task' => [ + 'handler' => app\process\Task::class + ], + 'email' => [ + 'handler' => app\process\Emails::class + ], + 'checkweb' => [ + 'handler' => app\process\CheckWeb::class + ], +]; diff --git a/config/redis.php b/config/redis.php new file mode 100644 index 0000000..0508ef6 --- /dev/null +++ b/config/redis.php @@ -0,0 +1,29 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + 'default' => [ + 'password' => getenv('REDIS_PASSWORD'), + 'host' => getenv('REDIS_HOST'), + 'port' => getenv('REDIS_PORT'), + 'database' => getenv('REDIS_DATABASE'), + 'pool' => [ + 'max_connections' => 5, + 'min_connections' => 1, + 'wait_timeout' => 3, + 'idle_timeout' => 60, + 'heartbeat_interval' => 50, + ], + ] +]; diff --git a/config/route.php b/config/route.php new file mode 100644 index 0000000..20a03fb --- /dev/null +++ b/config/route.php @@ -0,0 +1,61 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use Webman\Route; + + +/** + * 当前路由规则:/{业务域}/{模块}/{操作} (可扩展:/{业务域}/{版本号}/{模块}/{操作}) + */ + + +// 管理后台 API 样例 +// GET /admin/user/{user_id}/tokens // 返回用户token+元信息 +// POST /admin/user/{user_id}/kickout // body: {"tokens": ["..."]} // 踢下线 +// POST /admin/user/{user_id}/kickout_all // 一键踢全部 + + +Route::any('/c', [app\controller\IndexController::class, 'createResource']); +Route::any('/perm', [app\controller\IndexController::class, 'perm']); + +// 飞书 +Route::any('/feishu/event', [app\common\controller\NotifyController::class, 'feishuEvent']); +// 飞书回调 +Route::any('/feishu/callback', [app\common\controller\NotifyController::class, 'feishuCallback']); +// 代理oss文件地址 +Route::any('/proxy/ossurl', [app\common\controller\UploadController::class, 'proxy']); +Route::any('/upload', [app\common\controller\UploadController::class, 'uploadFile']); +Route::any('/upload/image', [app\common\controller\UploadController::class, 'uploadImage']); +Route::any('/upload/delete', [app\common\controller\UploadController::class, 'deleteFile']); + +// 公共 +Route::group('/common', function () { + Route::group('/auth', function () { + Route::any('/login', [app\common\controller\AuthController::class, 'login']); + Route::any('/feishu/scope', [app\common\controller\AuthController::class, 'feishuScope']); + Route::any('/register', [app\common\controller\AuthController::class, 'register']); + Route::any('/refresh/token', [app\common\controller\AuthController::class, 'refresh']); + + + // // 调试 + Route::any('/feishu/auth', [app\common\controller\AuthController::class, 'feishuAuth']); + }); + + Route::group('/user', function () { + Route::any('/info', [app\common\controller\UserController::class, 'info']); + Route::any('/get', [app\common\controller\UserController::class, 'getUser']); + Route::any('/team', [app\common\controller\UserController::class, 'team']); + Route::any('/resign', [app\common\controller\UserController::class, 'resignMember']); + }); +}); \ No newline at end of file diff --git a/config/server.php b/config/server.php new file mode 100644 index 0000000..054d01f --- /dev/null +++ b/config/server.php @@ -0,0 +1,23 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + 'event_loop' => '', + 'stop_timeout' => 2, + 'pid_file' => runtime_path() . '/webman.pid', + 'status_file' => runtime_path() . '/webman.status', + 'stdout_file' => runtime_path() . '/logs/stdout.log', + 'log_file' => runtime_path() . '/logs/workerman.log', + 'max_package_size' => 10 * 1024 * 1024 +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..043f8c4 --- /dev/null +++ b/config/session.php @@ -0,0 +1,65 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use Webman\Session\FileSessionHandler; +use Webman\Session\RedisSessionHandler; +use Webman\Session\RedisClusterSessionHandler; + +return [ + + 'type' => 'file', // or redis or redis_cluster + + 'handler' => FileSessionHandler::class, + + 'config' => [ + 'file' => [ + 'save_path' => runtime_path() . '/sessions', + ], + 'redis' => [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'auth' => '', + 'timeout' => 2, + 'database' => '', + 'prefix' => 'redis_session_', + ], + 'redis_cluster' => [ + 'host' => ['127.0.0.1:7000', '127.0.0.1:7001', '127.0.0.1:7001'], + 'timeout' => 2, + 'auth' => '', + 'prefix' => 'redis_session_', + ] + ], + + 'session_name' => 'PHPSID', + + 'auto_update_timestamp' => false, + + 'lifetime' => 7*24*60*60, + + 'cookie_lifetime' => 365*24*60*60, + + 'cookie_path' => '/', + + 'domain' => '', + + 'http_only' => true, + + 'secure' => false, + + 'same_site' => '', + + 'gc_probability' => [1, 1000], + +]; diff --git a/config/static.php b/config/static.php new file mode 100644 index 0000000..6313679 --- /dev/null +++ b/config/static.php @@ -0,0 +1,23 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +/** + * Static file settings + */ +return [ + 'enable' => true, + 'middleware' => [ // Static file Middleware + //app\middleware\StaticFile::class, + ], +]; \ No newline at end of file diff --git a/config/translation.php b/config/translation.php new file mode 100644 index 0000000..e27f629 --- /dev/null +++ b/config/translation.php @@ -0,0 +1,25 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +/** + * Multilingual configuration + */ +return [ + // Default language + 'locale' => 'zh', + // Fallback language + 'fallback_locale' => ['zh', 'en'], + // Folder where language files are stored + 'path' => base_path() . '/resource/translations', +]; \ No newline at end of file diff --git a/config/view.php b/config/view.php new file mode 100644 index 0000000..e3a7b85 --- /dev/null +++ b/config/view.php @@ -0,0 +1,22 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use support\view\Raw; +use support\view\Twig; +use support\view\Blade; +use support\view\ThinkPHP; + +return [ + 'handler' => Raw::class +]; diff --git a/mzl_private.key b/mzl_private.key new file mode 100644 index 0000000..3d5bcf3 --- /dev/null +++ b/mzl_private.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRQsAoN1+7lh7B +AEEbDNVKTu8sYHifkDjq50meFzzbfQ7NmcW3F2IOldRFZ6duETKn/qazvTA/wmRW +fXw/AVlEUppYBMO4kCIHl1ETZ5cCdDPRL5IBjxnUlH/bZpo/ox9ot4nFtGnHee76 +KnVjNghakThwluDnj05gqMF0/b1CE8QvS62LygXhYL5c6e1hWke5cDjhsoarZGBA +IRYsibSj3hblesqrZhVqEZyBUeCcUx3XgylEmr81yQiWgNZ+u756chVgwX7m7lPs +dy6KeJGsn+VxXUC/C272lfSuJQVZ8lT6QoHa3M3RiKNnxsnXJXzmiNwD+Ot6WbBe +MEl62RbNAgMBAAECggEACL1W2CpXFOpBCfXjdWh0s5mwJhrRvtxGCRPnmhlfWpos +MMymZy5FudU3gqSNgUY2AH7De8gMrutrLfkn4/VZXJMHzJUT9YfZ6Mlz9eScK0D3 +wkAdD9qbQHeC2+kHmF1mnB5NbzsVnEkMs/I4NkOh8RWVyI7MmtWV3bYxyjVbHtpj +VugJLzU2wt2T2rSSVN+sG0HlGqkSyPqP3R0Rdquvfry/YNA7wbSqxJaF22Mf0vOh +fo9mkkj9L9VZ+R9ksaZuQEYmtqYq18LG/OhYg4gTcZ9JBQ7fMFfBiRR3SYws7Suc +Y9Izp76Owwpkok+O+XJNpHCx1+gQ4IKh+Aj1W9tL4QKBgQDvvheKn0Hm9SxZq8cq +ALthISdlcg+8YLmhGWLxFxDHAd4xNKty4sj+Q2x4Mw8GcLWYIL7HoqBF4EtdQgMw +uTCHaxPWLAAuaU3RJZLOKxXpkNiedo9bcYWtz7kXiS2wOecVlVNEALaLEDmkHBZi +uUV11rGP0o0qWxWpQXzxFhDMHQKBgQDfc39YL1vbKZDa2XtoOoHL0usHVJuhhGhk +jSxmVfwKk/6FWXPAiuQluiICUOtMTo2Ips+5O+KZNO39B9ZItwaUoGxOOhaO31OA +2gQIw/fWuU7FDbnipSydCWHPWPZ3EngjeQG8EOtRWvHQ55Nc/KoxZhABlZcdw/YQ +O/lteU6WcQKBgGvrGPPInsTVZoJzBfNNYwvk/bP3tUeqDsWLBPPb2yyKFbf2qprL +pYh3V1OAQGo4wDXbv6Vj+luaDLLNLVx4cHT06XgGVIqP0nysOjpeQL3j+u7SFdQm +9PL5x6rlkFB8sB4SGupIm+NbiWbrUdRc6/yQiVthfzQqzkRje1eXBDatAoGBAKK0 +7jBfY8qFTMYfAL5Xg/XP4nXXBPAqgv1c0sy9paZqNzYQebnlKNXT4DnBhGRu+p5b +oPwngAvBP940m7MdGryUx+h6YbytH5e+qJtdXxVKn/Ha2dUQIxlCIKekBuuTVkm2 +Li4sP71gtJ5CUcqu7JrFz7fnnj79VyphEXHGmBPxAoGBANDNiLUIq84EFZJ2Z1QP +85glI5Mzbjbg0fJWuHYId8+ZkL/PpQvGMnw0vPt6rasrX9D3FU1B3I2yAmQVswhN +SoeTpu85EoNJp4rTPiRrQjvlcuMuxJqyxmksJHq981PYn25BEbhw/UtD0Qxc6LC/ +T2us9BEQbZmq/K9l9k5s2pQ9 +-----END PRIVATE KEY----- diff --git a/mzl_public.key b/mzl_public.key new file mode 100644 index 0000000..a479156 --- /dev/null +++ b/mzl_public.key @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ULAKDdfu5YewQBBGwzV +Sk7vLGB4n5A46udJnhc8230OzZnFtxdiDpXURWenbhEyp/6ms70wP8JkVn18PwFZ +RFKaWATDuJAiB5dRE2eXAnQz0S+SAY8Z1JR/22aaP6MfaLeJxbRpx3nu+ip1YzYI +WpE4cJbg549OYKjBdP29QhPEL0uti8oF4WC+XOntYVpHuXA44bKGq2RgQCEWLIm0 +o94W5XrKq2YVahGcgVHgnFMd14MpRJq/NckIloDWfru+enIVYMF+5u5T7HcuiniR +rJ/lcV1Avwtu9pX0riUFWfJU+kKB2tzN0YijZ8bJ1yV85ojcA/jrelmwXjBJetkW +zQIDAQAB +-----END PUBLIC KEY----- diff --git a/plugin/webman/gateway/Events.php b/plugin/webman/gateway/Events.php new file mode 100644 index 0000000..72848ce --- /dev/null +++ b/plugin/webman/gateway/Events.php @@ -0,0 +1,39 @@ +auth($client_id, $data); + } + + public static function onMessage($client_id, $message) + { + self::$wsEvents->message($client_id, $message); + } + + public static function onClose($client_id) + { + self::$wsEvents->logout($client_id); + // var_dump('关闭客户端ID:'.$client_id); + } + +} \ No newline at end of file diff --git a/public/01.jpg b/public/01.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d646fb21ecd05aeb56afeabebd4abb89699eb22b GIT binary patch literal 36265 zcmb5VWmFtN($f6;*2M-clgS*2W z@_gTU-aqd>=hp7*)=bxSS65e8S6A0Q{C-#huodJWasUDX03d)b;9&*fH$+<6SXEs` z4)R9!KLx!22*@u1z`@boRb5_+T31h>8g=PEzj!QrYv$(s`2D{CSnv7NW9U$)BoTikN7)xXLpcB^$~Z~RF?*Ea}Z~; z`X9LI|G>?hT_5SAKpGJTdymJq9^sMWrxuP{8sIk?c##8cfI1)#NImu+ya&N47XSp$ z001fOzt2q50H8hu0ElP*`;0CJ0I)s+K;6iHpZ!-R&Tn1c{;Lid{6w_01c2ir0C=Vc z0C>Lu0K?#48Tj$P@J0<5k%N3WfiEk-99G<7R8&+9Gz>fp3_K=MLQ56K`;6%K2J5g;xGft7jZ9f2 z;-`dd?G5rS@-Qxxb_w3DcFk8rZ&U=-s+%w*%7QE6zsck(;8o>d1hXtff2~HRE+*xy zs%|198Y3fPt@e~ORS3pJ%+I06)g(*OMabR$Bvbuyw~>t)F;^6UiF{J3H%QVU#`I&s z2_I7f<~R0h{ZQ3jt+ZSc>tu7-V6dbrR5Lgy8BtC3iBgOiF-Io!i7KJiylz!gm4zap z`Hait6Fp!~NNsy4@e;>G)4maGn_v1ZAqpaCc@V7oCmRMgx^?eUGgGZ@rReyon%CcG z%}ibcPr7@5&mdz%P-@kxLn6{fO(l^DfG0r!F2MSNDF_)r%K=zCdw+u#M3oF+0vLhd z*Q8`*KoA7^BLJ#2E&xEN0XnjFa17}_LW&WhvGuXOQFOQ&L=BD+fQ>;1erkhFD)*vTFqP1u0LZEU@?LZ`AOV+J zDhhYj}-!1uYnu@BMw1N0*HQWk3$tM z5d_Kxu@f63@>v{4WP;=iY)Lw6j5s|4T19LLM2UZjkEn*6!vZq-&%Pl`QYT;pyYtGR zqXZSR0e}Kx@i)5p+&qi-5`@T5O+pkt^&fF5K|l~4wgepl5QmH?fsKxgEoq8D9ryaX z1RaVdE(SF&ki&+c#X8@s>7m>1Doos|8GJ+A(60K}T<~i|2^1)RqDq9o_b3+}C-s7U z()c){7xAi+=9)};$#e*&IHsKt)BN3$(uf2!einWS?qUF(eBhXZQ|A?84%p3zpw|Qd z0ybUnYes3AAcSWCoqe4362L*Ni>oyO^7NVwNA)#L4z6;4GEjqTs9B}{Lz55y-;aQi zLMtKhh_Hzg<0Ay;03adAGDc%h)%-U?T4D~#LCr@c3YG*OMaojHnScROBW@u|;sC%` zIzkgkreIasY13B;P1xbG5h*MV_yCD2qAI~7n*i7=WKhHaKnvo%SwQiJSKRyR9gh#Ezi32350hEXoK$noWp~M7I=!G~K45~B$ zlz}t=G7j}?6J!JvLjXa>+B!~BRrl$CJB}jpMBpJk$|2M zz(@Eu6>PI@{9J(sxq@<@$G}SH;;*^U4>L3JC34j`coG1~e`w3)%MHj&Y0++x=1aJvu0K{^z z{%`={1)xBeoAZtMQT*s&J^8y)EY)-Lq}v}2i-bg(3jWk@MQ}W~7d$}Aa@RA)Z_j!+ zGEk`28>y}6GvRaQd;W?!?lq7Lsse!cdS@$xcIOxAH`=1SH;+7l<3)!Hj{N(VnuJIw z5+Dt>G#vtR4q~w+9kK)|V(Hg&g!7m2a+0)?s{e-fNfLkpL5Ww-m1GNg&lIdC8%~{2 z7@~;p-zS3&+8_x40Xa?roWsb$owABxmjDD4Y&u*=P|1D(0F6XYI4R<5x-5cch#;j* z5IAwE0SUTjT-4;Fcd^Z)%Md+dqM<1nbvw%ZO}jf1%U2LStW!BPORKH75B zN!j3-kF;!IETtCZVsZY?&4O=^ZnqRSVXFvMwsn)F*^R07lS^6Y)M&}eY1t{r*^j>6 zg+4%xuh~}BwXYwdY&de-t#aJuxm|H))eKdDh4FMYyS8CDXYknnTp`(Kmuh_J^5;VL zjxcZg#7AapmqsPUOQ4fdY+%KkYxwZo>D=N(Th33R-?pAW^cJ0|V^Fb3e!9r*uLWRp z6u|tUy}@DT)_{e$!NW#j=0MRuRk^@Rg>d=S3WFq*kvp?l?^IihJ^!v%WvzQ_;iZ*< z1#yaAtaNW9>v9Q1I%EHu_7&hK+_TuZPaCG$;6!*Bs!X?bfhA1SpJ0>_9j911CYK%D zfJ$|Aj<-Tdzv^cyMwOXq^Rtqn_x=T^w^(y)#t!3l-aZ_E+5@lfdf9{Lc5;Xj^-Sd} zHjF?EWBa3BDP{^^2ZZT{4v#bw^@EAC2z}(wBi63G#*btN2a{RcnFVo^uR$02aVY@) z0b)-2Z{tSsN6ezI+;L+wTAi??VS>(+ zYG-G`6b3m*0q(kVHjN}9qjaCWiS10|r(OmhymT5 zqRv*LWih(jSF2v<=H1c9lExM;z89)C;_$I$KLOMbQ+c9LQ;qOg%E?Je&DMnnKwx4Z z-oxv3#^WT~Lx)JHUj;({CtI}Vo?*IM1RD0^mf`~?OW}O=iG4go1PKmT>9d+9BeonR;CC0ic(u&Ygax#Tacg(F-;yNViTKp~$Z|WLhr(H_N zRi6zMIVb~c&}4y74D=%YXkYbv zVlo^n1`r`E7#t8y-Kw|*bY2t^*I0N4p{ykJ=f)re0FdnrLW=~jWg`jL9A$%mTnKdn z&5wVJg#<3~Q%v9)HGn7qwvPL6$^)-O7NbPohAi6>r0J3yb2XgUAw3ybCAeq4e|cjuES2?WK zu4;9%#fna@Iwh2dkJc^B>MLE>GreX;j?89X>LQX!_S1l!#=V`qP4Q0SW$7ao z>^xSi7#DJ{{;lRHUsrmTd4cJHS-u*TO4+sLW8gwXzo5b||3{XGKsaHTlvR)s<( zHZe?=s6c6Iw-N%I?DxD9JIMt)etWFB7={^V@M-bspb9%?>=8?P*f0F-y)_bHzvb8F ze*om(;IkQPW>C+?nySW{_L>AC$bj?ee*#W5I+GzRT+qmjd}JkkRAoIr6G70FQ>!6Ac|o00nLO~3yr8=wkRw&gb-HS_XP}5SySJ{)TV35bJuxyt-QWQz=(;1h zT;wDeA!C_0{H#Jo(#~h5HFV;>P0@1p0J!~r05C zo?H?D0qBN3G%u+Kkkk=O z3!=+GyF?E$E%*eAjYhHr_>UhV0s5l<+#t~O0m$sIF}p1rwfE8C8v(cx*4jgJR;g%R z!%ofXu-yf|>dHzi+}n(GKCPE-nR%!T2?t}$_2+Lsu$kos3|)(8DL_8vc5KveOD#7W zT*z&rarUXmbbPV$g{0$Fw&RlF&9OLDCcJDt*YSJfHhd@Ep|B|fJ|)*|;E?I)0(onxEQvf?CGTRJ5KMe6xd`|h|$dg0P3V*161Xh04uSki0i#gx}DX5<-ykSN-L-y;q zJYfCS%+!#=NW4le%u&)^`nF?tO=noemM3-FsOieC@@H(03z_TI&$OpJJu46iS9II(zd6`$p;8@Ha#Tu{#0@i(jy(E8($2LSNU|d6Czhs| z!qW%W8QM*`sTsK}yNq70e6}*_sDo3+{D(t#Jj~o75h|21zLj!cIc`x;(9$i!*df^S z>C%d1#M^D?M|$0xbiopjBae(L`Txx$(ARs7h&DQOL`2rLM9w|mJU?$QPYlS%ziCya39e#kXJQ;(i7DZ{VYMbXAdaV->Uaz`9phopHh01}`lH?hjmxOFkT zvSnWQ8%k-riC*aifdon)u4B>+;NGijeSslIA zBU;`1VMLnOyjRrOeEv;!bL+9PRpFK6@ZSwr4P!lng+yxNL+Bh)S8Wk(VF6URsw;x? z+*4&S1xoMwxweOn6m3=JU{i3Fl*Rb3Oq$Lr5l-N>%{nVBWf{Cn)q56VFUjD{wf|F%qa|XTzkFA^I0#LWf`VNq`Dk|r^Tvpbu_GRSXE!Z zu!T7a8r^@kI{ARM4qdQPE*jkjUd`a%5>fy;2jC88)BJ`D(bk2o`hb~$oc^PC_}8TI+$2=0oAq<+Dgp4?O(r?Qd1-Y8$~ zuj_Yc12y6ys}{y#@gE$jHd4go;D4_al`1}(tu(73eK@?~X=sJ&dU<>=@UK+;;JR)Q zkfqdE5fT@*DyR&XRmS_U5jDO$ekKK$=~vv&R}PH8)ELCp-HFD3rHg}rURoTu)dM$# zbV08{mlfRHL~_z(iWqRa9+Xj0*23r9lim`X*k3tCibs7Gp$!)apz3J*UUfywmr%I| zv$>XD_77;Ii3r3C48=R5I2>F~U1V!D($n#XOw5>a7^oi&ybGZ1X;9YraJiuKd?k}V zGXswMVc_G0)dR4hLp>N?IUJi>ic|f? zzyAKwBf#`>6OUK&7s2{)D|0)!&Rr=|QX?>#oiav&acxiN4e|#YwIby$+CJWnG zU6K-X9N4d7cErjJt()EOnVVL+iwO+(8fY98QU;JnQf7ZhI$e z#bR+c{o}0iJs$KMBjni|l1d+hV>EzDM4(D}figb#m_Y8TpO@)wr3KtZf zKlT0o&?g(QqXW0QuWR|9I@BCbwG@kn4{~}ts$jVHZam4j(*2zmclBJ2m;bb&`3iKj z1TNQ~t*g}4;sK@nUZ-Dfgi6Y_9N*F4kh!gBWN5DdA2ue>oOaAIJ`uH_Th1hUziO^$ z_3TGyPj9I*ZZh9-{n@np12DcuShN0C-thALnCWaBN;JMx!FMm`C8aZi&YK5ISKoNk zmrx(g>{~QGU8PKQv@&U#uo>)6SpVI^+(B>$PWnO?wwr)PodA@eCE!F*KYB}#VHyDf zY9KP`S&pPIb<(40OaBG%{EI*+>>~%OF~ospk2DSepG~tGz@dA|h9>h-7qABY1lb~6D&VTs2bVk2O|P5$9&=zV7A7;YDO1wx5GxLq)R+>PP2Ka|BQ< z6g@I^r!@MJ*7-8Wz>Lo~q$cKBE=h(Kl;(HzRAJ|qh2qbDc4Q@Vc69e-wSCdlKoj!U zhbmKr_)&HE@g%1veu(d^sCjW~9669Y8gUM}IQVlSp0?eTB1!60J%j6$7`fl{#jVGP ztoKL-GX_P(j5B0n?N36tf#(h5q^Mt=!=*ux*`?bDwT`2L4Od5vrc{kOmF9v6AS;1- zCk6REa`1m0Suk!c2}Y#|8jFo(lm4LT8BVi(559{ifuFEpTtO3#OOQLI#v!Sv{2Vq^(b`A z7rm7g%k+0^^9H@dFWtJF8R&Dz zL#^{x6x+!)IJ74NpHzmedbg2z{^TH}ALCF->t>!Ifc?43R)U3ieV%YGmU;l5=?Kp0 z2lv*9ryH3YZ$zZNKFG2~w>dlbb>O^!*>1Z+qL*peFIX{FHJcYbWN+H&;mP01VLmmt zGjJ?#AdVGmrW4sIDDd=0(&=Vjykja`Do@mefnDywBrNjHh&)-a^K)9JpS!lMCtM;W zT_X&}^N{Z=S8($=yUo2B_AiU=nW)i+FxuG5(j$-;Ee^aSTI7pysE+p9#q?a!G-~Lr znzkHGA<6j9ei(}i(I=g$mlGazYZJ3OqeXL2ob3jA;uNj169OzUmuLa?%(5omz1G?3 z2C`dy=RBeW&ttYUHZLcK6P3BW)@)i2KD6R?TO57G-=o8I;=|K zRpzP6IyEBNI+hXrTP5_ZJC+RNyBGHx!f%JDj&55ZdA4{_Q!D4;d@g28)3CC&p^$HM z#q??T2p0%iVE88i41OApl>#gr0RQ`^DVjrunt)~TfBY?=DX8haI|MDsmPV2ZA3)GX zLq-9k7)XdekU;-R0*sOT2N1#7KJ_DtU_d)WsZ*HkK6UQxD7=J)anw{05Qy|sPhCGn z7W>%YZn<)&W-{H+;M1~m3{`wa7x&x9Xc=gVvVU&dV6VxlTF2pbXGbZY**FTClvp^) z=&~Q{t>TX(9WpONq>v-Sa>272;elxPsTQ)iMPtVYfcDQ;vNkjvs6v#a#|EQd;QoO= z7z|=j#{r0+9!)NY(IbVeEaDz6VjjXRj|}GVF6~Mwtn6D79spzMB_qN6moAR123^Zl ze!`T#js%JtcDEY)_p?lQFMCI?>Mw;!4%C>vcTyDKjXeP#QsPDt?Y zw!rvGWUAF+g%3yT?y7~CN~opThK7o!GuSB(yob73w| z1739e9qPyyCG1ilQ&(4+De<%^{O{&(MYSdtH6rpr|yZzktJ27c{Bh#XC?8?gY zVEQ%fPp`y|Ox93oQj$z11zYZjo>G@&j}c#&(An9IRGr(A%Iev}$|v=dUCC=|5 zS0hw6S332BD=BkO?b&mqo>8ytkV{#;OZe#WrHxi98CzuGtE3`I<$youWr4Z}@QRh^ z=ox;;4$Jjp<+XAO&5`uPQ)354jSLrc_hr*xjRd+yus)1O$96j08e0wY@6I?Db!***ZA{x(KxKkGLR z%gY_bl{CvJzY@>F=FJ;Nl$9j!_@*nrt}xY8WrrQYggrG@1ZUvHihUolB9aN1M#|3a z_hdbloEm@2Px=W3sNgmFsrSigX^@z%%n%=6RScxNO%J4SU6+t$%72o_4YB)ZNZELj ze|nHQIuIBWwS}wj(6W?z{??gq> zEo0s8@}lxcgo{d?puVl0%Pwde=6_^~e*F`txC#WT+3e^0y(DQr^jYN=p zO)G;99=(_Wbp|88 z0P<^G0PS&hegl5}Tao|?hHzvs)}jg^P=k+=aV3z#vVI(Tb$c40SE%>Fp2_j4q-kG! z%pP#uXFdHw_W(>N6FdOhzFvIlm(A0iZ|%<4*Il*`{*YIqJC6`b?M77+x%{o3FKFa) zwuxdc@hK5N;lBo_mj*5TCun1rK39@`Fc}SrWo{y8j!8`1zb`y;Er^5sJc=b)@uU4X zXN9zMWvKHC${Io*!*w;xosekER5-lEbS#Fy(#pExu46^MEa*q!3U_7AEE3pm@L~56 zm_PfrF|~0)b4@D&Zuvk!1F#XO(EtQ$0GJwCA@+OKpqCX7#m=0x89H^iCZk6(7Uv-g zTb@hKiq{t7Q`^W&5tU>5J?@_}f^zGU^!1i)Rhi#)r~k^qJKPRrUPA2I?c1Y@0L~-Q z`q1)E&VQLTHTU&B`55LW0u_9BuTO6%pAkUcRLSLAq%CFEKw=@4X z-LSOS*@o}jOcbVrlbl39K=rEDd?kGy0=b2-?*)eAEk6JR8;ae{j1ex1oAIx>S(Dao z8t8i$9{|+(!*uRRF=fY$*&AN#vxmO*u~L$UzD*T;`HjqYJE-N^U!gv&d>aoFHLt(U24~- zg{wIedP^A5rO%Fl6UC+eryuL3zOwyeDr$?#Y^Zu#-&q>T(2Ent|#A>k!TePR)Yw*vu<=dV(o ze+RH+>rJy)e}{2?BzZq9!O8#X`8;D8DBQEyeF|QlKK#muB5C#ck$AN z^<{Id5eG);)fl(alhOT0D%FlJaO(9s^9ZCyO`4UX~U@m%nNm$<{KjsQjJA7jFmWhx_DJlUfaLJ}qc8!`zFDI1W0&WA&dSgc!Of@FeV0!RQTkMRz0+6DoSU%~%fh98eC9)TL% z5o4oE5MaW24UsXx!%JST4Q)#*%({EB4|jRVPm)};`b(6YVENY$>}_e0@1b4QTx*vL z`~*Zrhdo3z+U*rSdmJVA*W9m6kfdrNQs4+e_FK7A2izDLc&^^Q9!Z*|g? zGhT3o9i-k}BToBvif|a3g^pm3xa*iH_sNPm_@&!H-2?3JUzs1VQL=|E?`G~c9lDh0 zv>P_T`5VuC$0DW{m0IUSHp%X3T=uzEnZ1HDo17gA4I3uL`Byc?Q=tWIP*@Rdtc&pF zG-3aWkS)t?21Ol{Ll@*?YHsYxsi;V*cL-*$W7KWkRWQ**Tz}=8maE20q;|o;)JJ?K zVdlL3yK@xYuQi?jo49Jz!f!rB`MIJ}`}f)NEg#~=*o@8;IW6tRm9mq!hDi+P2{r;J ziw9fIFDM)3yb5b!(LOocE=-f;@E%HKO~$+VY;XDTKpteuMzN9$Ih|=)LtfOJW$*D= zlp?|=`*j^5<%ZLRvGMa$pPOu>!`j*-Wr8;^n8vPjM77;qKpB%wZt5aX4Q=HCp11EW2_^QEE zfz9HI8M_KuomMdXe|GII{QJs7>1VS{ZjyFR6BYa6-rp~LsLB{xMt|I3+=+kB3L7gR zHut+1mzSwOvAvFM^`};O7w6V2j6%6*XH1jj)GC-;1}0N!LKKrNWf9nM3N5ri2NJ*} zu*5bemFa9`(nD7-?KQ6^(E^YN)Bze4&@V>^*P#5@KqDPMU;+SO`T;-<>ga!6Nbs2L zUj!!zK&a7(u#yj39-z^kNn_zO(x$*|>^Tiavghf#G>bmU#yjF*6^MR^x^#KoT^`uE z;=1J>rIPL_b2M?94--!Os4qE^hSwNIJwjL5IRpA*70eLnRB1s z-Nx0JVXFRY?B%7eaQLQ9!etwJ>Qj%I0ZmFN_zXpWKP;UmVkZZoMS_@{`{-z0zqdCx z)og;+qLe%1isAY$>V_`Iq>__#Fih4PU(a=iWz{xm^~_)WhnW|+^OMJA?2@eA)`M4qz{H ztoGCsvw;-N!A|cI=0pUZ4JW4*&t4VHitXL0sQvIRinn6ZN$VS;Z{fH`S~R^(qzYSI zsl_a>ABaeB-A10)o;AkX-uvrmOqG>(*qSzs-%uf)>VG#~vR>s<(tSepxk$`$)cCKH zlrT+ea&)q`mRc73c11&NVN_4j;oTwZf|c!0w4JNJ_{q5_pJ&N|QLG%hwyGq94mRcY zr}ja;pvyqWGDzLa8;Mt4TxClA)ihrfu&jWvff*DY6aA@DyphOX>2Pg10K^H18wouj zl1#kJ>&N{Mn5O}rUV%AB*pM6mfjWry--aUyR6Dfa|17eXF`-vrccu=1$zdh2+Rvl6 zmXn&xd>qj0k+c)>(&^qaL$W7^Jm_XKS!vlER*V7WtE_a3wW` zyVk&v5r4vpKunJzzpj2#tQal>#gh}>58+a;>$g?+D;c&WpW98j)AP@K)y(zt#frh@ zsmr@gScI}hHZS$`-;c)YsfD9sE8f3vd7jVD&-rEAe&6KKp{Hz|n=n?%{%S#HCNGW@ z>3mP`D632I8?J3ew-+N?+GBK4v9mR-66HtnQ$&Ycpv$Est6{3!c1HHZl@Ttpo* z;<2l?O?xB@;T_*A`aZxT{o!y>8LPv3`Sw+jG6l|h>2ud?nuW8vxzW}%RN<(jR-*P7 zGM7C#o$Vo0OxnlnsvD`_-+|gS-_@E~O6$pYx~Ms|YQdxNG1W2J-I9EPDkO_(fCrQC zf`F-O=8gH7|FqUz8Mo=`Xn3@;7(3s%>`Sd_sLNQCj(NOXJ;{=vaMU=|P=~NQeJ48& zMer>0O7QWd)RnI^kGQKIzZTBFe&JNcO~Y#rJPBIy%HUx=tTS;gh&RUThp84Z@WQ3S zI42ez!|V%^%V-N1e`9stb9WE6ulh-mJ39JF5s8bt7DF-%EqunN&TTzIJB=+-<={f>HMXSda?ykgwSOHn_*xL+&#W8u8DM%tGJOuN zQ8(^LWahGG^so?oV^vOp;@#<_I?Ht1*1|F*c27Yi1<~=D{admCKjuET*{D6E;PhA* zNsr={v66=fh1z`lPP1rsyliPHJ<15v{kwv}`oioy>T7(%%|2G#m@i}c{?D#S6J~{O zd1!l0I?cNpPZlY9=&)$D@GEG{;Cyw`;m`wG;^PfK%v9O{Z1)tKK}^AoNl0svEopax)6_}EzJ13V8N?4J;3R}2KlzarUmYXs z`2-sMxlUpKiK^7@YcO4{D_)_oRY$nYc<9X)Lu*vOvWY{|{YF@*7L5i{>T)HP^T;!k5v<_V05U?26OmemF(1Xk8+JMmcSYYP zVR#`fkzO$x9~%Vl+jDV}3LMQ@D;#|23g;Hv%wUmQ(Fpyk^KN+We}-F0bEvi27AsPb zzT@y5Q%j7P@LYtoeJxwbbix@4ReS&x*A8c0C##h8H2Wv}+9C@lG#l&ZX8eX*9iMaf z8)uc`YB_oIjx1p3Z!-m!UA#bhn7AI z33^J+NbSzw+OSN}_D$8!4m)g%R=gV*WoyLu9z4T(-V^Oo9$9U~`<4|>aPbHEdwpXo zABEsW@K(m9avMfp8)w*PfVPOsUNweegJFeYz!;SDj9-xk0eD7k#JUW_>#F|SxAK{H z!$4D{an;8ahUrjGEEe8!7SL$a(;gVRfVO;#dBghY(B8o1s2WyX{;9{YbTfY?h&~*1bi6#!;_>kTc#}FLDqdwFzg*JQt<2fTnpf3zX{3VH zmqqe@PEO08t+ZFLPdx9`ioHjBh-09dBR8Cc=6k0aXC71E@VvW`XnY6>{`a5}bl3BJ zi;!rabX)&7qyE?wq%fC>7Nvr;A;JuyjH3Nh4|#QHOr7wAvOnKnTb1fpf;(s&%+(#kRS!7Dg7T5z4?a zUHB-tXVR@w?|MlG>nzNhf9lO%x)5VulZo_$TeVs*xa~aX6UD|btKfbZ(vFl}5)Y9@!fJQ&< z&tXdqfqYf%z{(C=0o z{W+RpQD-t^K@-))XdyIeJ|B_?77$mhilA4qW!bb(+pk*_=!8=LZ*9KWviu< zp_O)BZS`T*?&O=sxJgI3Zu6)idLA~5s`9$z$cZ*?|7Zbr!sy6f*7GvyteiTHP@R^* zeI}U7qVIj^AY&UOp!>VIm3WMN!~hBmMFivcDrGYReYlp&IFw!VhT>UtlABlT>RV5u zWiIAkBUc@TzT-yi+70@h(UGEn;`WvD@~8lWR5me+)Xr)~Dh}e`DOcLO*A7Y^`WNyh zK|+)i%UaG+LUI{y4&Hbth1JLO?#s;Y3<9?tj{JmqoY$3vW^@JaGpaI$a_Wg8A$QQOIq&-y`ParXlnIq z!g}Xoug@MDB9X`T5tlG{5p&zS{P;A8kYHg3bj<&g`piIQN9D)WjYUn)f!4nMnd5e$ zAt%2`Cl#pFtD6_O&GVy?;|2zQy<6#%&D~18v#gqD0SPEJ*L&Boy8n({Z{5h z=aiqw@$<{cy7%{(2d<2Nzaa7D>_0m=6Bq2?*jOjXACX>Z&M4{;%oxfnAJ7>lR8ThX zNqyJ$bz{%RhmGju)fxM+2~?4QWR*Qd4y}e)JKe;Hxjx@s(#E<=1Co~bo8;~9ni)RW zr!-iO7LRGlsfn896tBBcP{y4p%;Vp=^zjHB@_gQJ{XNGD^Hfrk2G z{nz2D-7U+hL?xee!weSvX_pP_T&34D&Z9AER|v+^i5|m=_iSo-@O$pGYc0w*fQt%FFiGnDx>mXdc67B8fhe= zXFPJy2+>Yo5}FaAAJy}mntTmIr#rt-6~D3(jtaAW>utzN-e8d%Vi`7vf3B13D~U?@ z(paOteIg$3-EzwHU1?G+A>2AC$UZuCl$3_gt+Ky}VgQq}Ir8p>#e#aEzxXl#R-I$R zNq+fsXR3Ms7Sl(|O{a;Z)3=?MY{Ucr4R-smZ55xLhhB>3#LL5WF>xj0r&qX&+)$eSYl7P_Af z`iZfBu;hoTwUKs}v1ALd8uQ5Q_{|h9oJKW7m%S3jx5|EA=!N+_Trcts(=m(Jv2!x3 z{-trAzWzZ4v6-PD76PpFYjWCC{jX@#$VDFEw!;32sb#P1r~5}bI+AG9Qe$IwZzBe9 zol1>2nJVG==F9hfhb3PuoQoLL=651zKb+5e2)8hZl|C-ZtS4gRdu}bu`EK7hT!kCE zWJxR1Y*V3kO#xEuQ(N5Hrjgynt*r2>kXceWaEP=YV)HaQhcnh7b2jKKqDr;h7bgWYm zv{I{P;BtRA3JcZhNYFy#Q_cj-31Rb$n@#DR(D!=6>Abx^sGF4R!Zm`qSyTN3+~#{# zBPT+NJxGRJ_k6GyIRoyCCrG2MWgWH#1W&{q3eM2wPd{pN zjdLd{%KpM5Ds(V|SxrpJ4RIzjG`(*CbEX9rOZWy>QyW#?ETUT-KUgEx*;0R+3JAZXb!|F&8RmIir zF6SlxHNCnLkIh&b$6PKIA$#yxImSj5@F<+-a>F7?^NIF(>KG@vb~bx`R~iBaBUsIf6Ru{=|KB0v@Da{tv>0;XUvoj2-08Hg3mR16mn zZ{ev3kK2n{ui$V@=dP%sqj5W~nG&&2F6on=YoZnSq;;2#X`8-L#aD5@J&D^sr6zM+ zX_o!N^^4I{`&74FBx#vlH%?bOg<$WzeR-}|w(oMZ-J(p_84`>(#lu}#j&jH55|t|a zB1Q~bh0{pp`ZV@hY9g7f^UIU6yV!TT>>KUo=c8ta%MEy@*|#=MNM4xd?ffciy5%>l zmr`<9HxSj~5wU!hFkw7+rkipyPMBR7u2q+>XoR!GG6(@|u;Q*PKB* zZbGgxF}LE3jG9+Lk|#cE^T+KP2s2LQ zk}K8uGbije(vx1`L+BzGp4gD$-f~H3z2Ig`;0D+{d-nXWj8^(#O6K1GT(h9K*$l2IMz+B3R#gYnZ z^by)9t3@=%jsug*7f*JN^(uCHxJPPb;lPW^D#&qHi$FmO{lxM7yYQtMw@`VszBU^C z6F47QKlWU9lRvrX15he_xi~+m#iBjL8KU5nR&ZjOjXT&`#?fdW9ZRcdq{6GOQmMzO zGGDwd-^oA9s43_vv#qLJKSaX!cfP28(Qe#-$WAb+B08=*Ttmr8fyGwYU}w^Sp?2{) zecnnS!c?A{S3xMi@~c7Nm4-Qei7gBJtD6g(it9W|DxBULjM9UzQhQh0 z$y|4dz8k#|c>wUu4u^^20|^G)$_P$aO~*5~9svH9YpT@CR{wdOhz+Z?mOqK^u+%=d zeHEYM>uxRxj@u>QUjsr8_x?UbC!gInd99VX(3!6V94(@X?Y0b_IahGPUAs{qVng?! z(7|v&GOmJ&roiVx_Om@Z73q@|-=ovdD0@G+;*KVWZRBQ4i%|Ux8fY03H{7*g& z)nZ*fc|%hP`>>OHq)(IE&}tsP?I)^}=H}OAr+-Cms89Z7MCHd)PC=z=rR@8d$x#FO z!gk|@< zv&;iEc5#L8^*wdI4+&fE)RYNU5HsQr;Php2`bY7PZ|9W=db~6s7<3?y~TO;sJ= z_1uk>R{!m2fD~z=SXJW5KRVNzwFld%ff@a9=4e-Q^u4j3PR3lnp$wf(N>n1{UoCFg zdXB$ZV%?`?IJPrNrlUq68kdvzTSmy+hoovQY9jS(u#-M^jZp?OY)Fr58k(W&r+>{o zVK!CI4qu(Y+lyv$Ti$%(H#ppSX57Xwy0( zOSRN^qmdQ&_q6f)3n{r>LKv#Sri;tQB0T^2PhGc0Qb7SM*IT}Jm0S+D!@067aoTNY zgkQjo;e6$E5#BX2+?2(n)ZssC*-9}z=Nc<6FfnFVeoRv|P+ij~wzF?-?#Scg|Aq>` z)iQouM;?;0>9#vWnz%mNCc}r4%j$+nqqWC3bRm4pBd}svnQLeB#Sv|`yK1t<=VPps zR#mMdTfr6II9>iQJ-ITUBusnmOfI3(^^ALbs)TT6W6Gk{khZP=++#V_phU&rKisrl z=#!-&1mfUH&ux+g{@42(88TjBQw0K3qab|VGm3mmEm2OYqDA<{bMr>0`g&vOy4+t{ z(aJOpVHYZ40nh##FWfWhP5NaDij+-R3PIB9Z028@9q-*418G#1tuIc1cXS zqJe8Sp4rt`&eN`wyNO4bSrp3)h(X0jhCMkvDlKo?oZ@>|(q0ZM+uqKLq|d*jH|H&L z2^<=^&$_07F?M^QhOjm^bFegoOb-mNd7c>HKLCtf$m16~74}#15h6;R4f}75v}!wW zIn$eMQ#(ikc>*doZ0S9kn;N>6E|xXoEx6R&s%>U3@>6;A6@C25W@ty3)<;5C*$hQK ztC=qpvADi1Ggk6WfO>2Aw?fm7*E5o%(Ur^A4J`;rich0*A0sb+jWynd9le>gz_cDr z5&gA|-m&aEyIngbZWN*{{@P?i1+&A`(zvt6%8H@9Mf_DmAE&Zof?_?7zb~g*WXG*! z7dH%6-c;5^AtW{M?r!bNB9AR+QAc6kq#tx7vy#|lo>|Qpr+0bAkz0XJfvb+oDB;qB zV7gx0{a8e)>;JHHmSIgcZrJCs6$_+O=|;N2fKdXDt}&%y(xK#@N6Ar=lNLtDkQ_ag z-awjBg2brNU7PRyf8Y1ZzH#i>!F^wOp1Cq-y%C}lhyKNb#Qr#Xl0cnnVTFeiMka4&owAI8a?nE-5T39*9kyQ zK{yPTgOHi37QDl%86?tQstlP{y+uXOR;5&}|=eZgG*yv=Vj_zSb7 zy%Soja1@?%z+|jo(|7y~wnFL?&uOwcUTc#MFtMnQHc*14UmAo2W;ULpg&0)2B?AfN zckA7PqhAPvZ6fo!ciZNtPES^v#!veM?0VAl+Kyq+0GYG8SR2;eW3#9O{=4QI8K}YF zOrmuzF1O|IoKtfl5u^AX65?+pg6t(n3(70jdvUO{-whKJ5V{Z9#-Anj=+*U1jC1$A3 z71n&^TcGo#;hG)sj6wGqM7K1j>G&y+eWknhKQ%ea>DACq*iRMx2s+dA{CtE+E4XIe zzB^yUS4UvvHnQO_nf&BbjUw8aN8Hu=IHDXvV8Y^ zi&^^ZM@d@hE3btfr$}End3KG~JdQzzHbQQ;_ksxz2z$EyKo2MR@^!SZ-aH5iDLSOl z+pbj4&=PR5{E^Hil%~yIF=N7XwwVj1@AXut*59Pz{Ba4gy1@Hg;eiUv{-?S4v5gZ@ znqF{K;Nz7Uqlp0##0ldNsqs)|vpBO_^UjCMF17o>I8t}RU!<_h15~$2)7{=~;gT`@ zmnsHT>&7-Z^R$OvSgyeMEm}pd*>>)*foxHopaUd_*M+`oQI*sZg#ueLwb{uxIVh1u z`=%{yo3omH(^ruR4KYxwlyg@3!+msl6RccEX^6%1Q-XI=vj5toXG)77O;2*jzdr;T z4ilX<)2w7u@A<0-NIr9!x2&;p#{Z`t@P5-NLb*_swYn6fh^7_Qoo@sGm@YK=!jXNS>u#sgNAIHT0$d-#BwMVT^N6yX*4^#uXz7?A~_%F}1rdSgjLEvyN-Sj3M?+v6nvZ`xLE3L)v zbT#4+v9NP3-ZYT1(Z6N_@y4W{#2N)gVxa0l_o)|wrkI6zN9xJo9Km#B{_;Xi&i+GM zcxRr&hg3)mq_jmO_N@3GEYNWJ2%Z_FiVg4CTFkPHeWm1@+_LboJo>o~wbkrd&|fP2 zY|Vob?@NiKje>?acTol1`Zdm* z>Tg=cT4{?L4L~~^Bj7!ED$&+teu!b2SJg~EIBDe2YDzDV^X_hDqlQuNON1kF8G4;n z7>7G}%N*2`f3!Z@G^b22-}?52duxA*lH$+ODJ3@3v_fV-f(E*M2hu-r8qx2XBQ3en zuqw6>-k9E~$Ldl31fsd`MjwPyi{o?so3-T*Y$B8Pm1tL$n(Y^#Nh&gm^U~I*AtK^0Tznz(KDCjZ&JCr59DY_R z;*f#h!upwFlfq-=1e$CGInCWvT*evgKGClF!28mBM%M}!csO5LdB@?lpfH=^_QWXV zKN}=})cAl2M$8FX3uT1rkqx9eJ#ri&$jSL`3f;Vv?y$$0iEi_NMq6MXs$88x#Q>Un z`B7s0W=ynIxem!{_T-FFe_k!Ocy8NR;D1y-{3h(}b^Hf@&2Y>X{>Elr zb=Ss;R~d1p)Mc!_vnXWx5p1Z}4h2!*JZ_$s+RHjU3@B^vL19kkOZ=x?rgS?Sf|2cH zmBi=fhp=B*+tv(OmMNizi+|8x$#G>u#CWnr65YBxXiQ+tDCx?Vu~JvVje*|hFJLKs z8rr6Hjy;7qV%4)qzKO)73|`U0O^=rxhVcWl!O-pHrD>H(w&`m7=n6g57HAS6C+J05zl9+nj|eYB z{Jl`@nt9mk<1Wq<`HG0C+M8pz1`^11_+#;~nvX4Jn5M{NGUWwNTFez1`{&p4{{et2 z+V;=g7(mW|!*2Y4dhUm}aAGlZ8pknTS*G5|Fj*@g6zkqOM<8UHXoE(kEPAGxmklii zMw->Q4Y+RqI8 z(qovQzdYRgVfTrh=Hz;X=C;*t#u1COP(l(({<3!Q!)em%ek{_LfL{YMf~f-e zL7d0i+w$WzQcARs;V+H2?)Bu2XVZEFKE<=>t%vzkuLsJeiZ8o?;?-k79BcO+Ko@D| zb8BIE)Z` zu&t!-cKioO>t8CiC!9OhW+8fLz2M=E(=d+@al6}EQ*|dypr8vkKl#(-8U=$3Gg;Xe zZB0#f0bYfLZG(Zt^Y!9w^V9=4V~?t`Fst~#7O6-deJ|Hk`#ielM%~oLwxW4x-o7et zHxPG~1_%3^Wu;eF^l*}-_Uf&T!3y|8wdlrR%?QWc_$++YPyV8M&mN!!CXww6R=~ z-E^oqNPslr3`z8tSgGFKGF0+^3YrWhwtql?P<`A(6!{YJI158}!hm9bALPUx@0D>- z1ztGC{?`*`91u)+M?A=cuO+%-wH)BIAH=VB8ap_dWo9z&y}XX=)m=}b{;zHit)0|vCEh4 zmZt`QS96lQS8|2p^T~Bb1e@aIKNOiCi`K%wP~z0|rsL^})!I6@4_&(ka7%m>MQP3F zfwl{5c8pPr1`$)4{(4OKzMk6Nm9TOWUcQZ=aau8S;QVVPpo0J%K8sCi}oa>nq$%$U^9zq7yQ{??)fy^ ze@bye`LbY4AS@u1E_vd(ZQeh4>l!M!Ye7MvVBWUcA!7<${ZenkAP<&eC!LnG9j9er zfSDhvLkSuzZT_q+geEmaahmyxozYIL5)njU{|c4y}v+B$Xm z_`BycMIsYi7;NN?6!tnI46eObmRabDGhwrfdr=sH;uorUvvQW<608RNE8dSnn$x8k zE4}-Gbc-nI%~G&0*DL#aj^S@Io4I-Q&+`SYM zlHJz9B0JH@oX3GL20y0g9}@-fUmmbzrPmSI)poY|UdqD|+g;>Tg% z=Ff(7ZAY>wdir7KN|#!`f4@BcWeJJTrsJpYf7@A=!+9UsKCnSyTDu+o)D0wDiqYUO zGK6V$03zx33)ULajodfl!xXdH(S=2VDUWqVtaTs;(8qNq$aUnm5(Tvdx+)t2mHW;< zgJaJz4JWmVGBN{vY`DCUhmcA&%ZY` zCaYt=QT@{>(cfXW`R-MR1gH*a*xDkcO|O+mziiMfOzfE){F)?IelwNxEm3pw*CHq) zmcu$E63J;N77>BsXNrL6`d#-pi{DzA;X{;8txEJu=fy1^Wthe=*mn! z7TRmqwDfBL=I;3emyex88Elq&?i-AU_B`mE4cQGIH&c;H$v8DkLXKu%QXjuNn=_+Z zoNDJqKI+ceHC{%hR4zwqRH{ZFhfJ@3Q=MTkwncG7GsFct-Tba>6y+7GFqP`hd(>4= zEzrA~b26@@NNF!FyHuJc!ghvy-^NKMI@_wP45t}uiw_p>+tM^?Pt*we#5bWTaY|ct z*GZLT?2HJ*L)eC;i4OZ2wm*^?bl=deaEqffB!nbSijvKnY+gq z><~x%IrHS6j|H#vS*V`qU#j$93t4b(P8wT0K0DDetiqreiy1)pZK5leO_;j*c0(ICbzo1-DPE%$h09GNd3%> z;1lkSlb(!mcNrbQDckH)p~bYL0StzK32iT)Y@~REyDXS3?X^}&Z;VgJW%QOo+OOrR zDPzuqNRyXGUEYGYMGFh26h#TAciA!u3b?0-dhI*&D=w^;7G^O93)SB1y=EZVrWfo+ zhSOHCV#wuu86;jgD5}WHQXpD|nzbiQNww7X=ikRla?sv5}5*<<;n zJoK>c8bUx#zxeB-bO+1RQl8cRv$32NBzj~G8$!NEEgJSF<n!cWlkvgYJJPjCOgHTa{mo9$T*}G0$_?5$HCJYR=bn9LGT>|l#zTkOd z=HQ@3!98l1ADy|$#*9j{Be8Fe_H@MevA}*UwaO+khcX($cWtj)IK@4CcEX0(?&goM zT!GA+?M$f_-`1Yu7xeRJ2{}0sr)x9)-k##(g^)S(6MZj^=Xa9MolI-zyY@r&u!Z&M z`V8j$3&}ECNZ$zjpE^WN(7E0VW+kgeU8!a`RF!(9&RA6@f~uL^^+xJ<_y0+x@!+00#;@QOZKW|*aM z>xGbD$N0gWqtQo zjBTUSxGgOhG|Wmr#F^moyUd^3Q{DfUDmE&qTEs1t*|5Jf6`<4so(lj?^Dn@pskUPg zft}J?J^gjB_Dw}vAA&#>8qU>V(|yxZye8eB!Sc9!m_3XIjMJnGGi8p@3q55!cv8Z@8&uc9_Ua=u;B zoGq_u+wbn}Lz6q=c@ z*~S3(a*ps)a{GHQ2QM`YEhe_HmLN0Yu`t$zL0@bnDW}qO#WCO{yrvDaQ0*=~kT-3~ zNC;-@%7Efi>e&`pk%A2nFK5r4)DoWZ&yYL_!UucHgbye>Ql_LTkg}r6c#C=3J zuKT|J_?N1as=wD7$iBQ~iB0A>UvL?3>CarQlfQSGB> zho!;Pm)Sao+AwWciD(FG50FSnyYdmxd%I+5+=q+srrOh4{`S@wj2_GbPIC}{z5BHN z0bQCuN60YF?H8V(pS}jJtt+`M{8NE0u<9KEA|&u2*|FZyk8f|alo%_9jiAN7k-+cBdlf4%9PA`_QxVlCFmcvoIUgXF+dyf% zlhCj*AKtm)K^<+o+^v+AXI)=-Ihd&@dY<#>^wZ*WqZq_+A?tYW7TU4`xg2rPZZN(@ zK#?{-boHRS42{Q?c+yaGXU29L0m1#5rx7OUjKM~kGY|a;OurV>{a3v1M*sG$Z^K5Y z37~Om+>Nc!)aV2zD0I;=(=$0O<79>y1W6Eo@j5<9%6`$x?elW{Xu`@NJ6jHK;(OXf z*%hLiB#C3@*m4B=BJtCq(zowr(rOa?(xcwTXY@5u-Q6P^sJP_;zBA?zrdj^OQ?c(y zpA#$tJzw2dsub&5i&a&V6$*;am2%i8@N(_t;>Ion+5f3;@4jlw0X(Orz0X0f7F zt=-J|zS~37F0`u7ef=-h?M0m@2XFGXmj%)qtrFf24?e#IMmkJCrg)o@-UFeu%%E42n(efik5u}buC27+v= zHlYDpAMxcE(d%wxo#k>~x}-7AqpSKy$C7{7d+lQ@jV{`Yb$M?I&m;d!Zsb&O2~uG}XO z7=?ImruE~}lC=LRs($~QS-2~qOpe<)J<;fKGzx5~HC0uL{yMth4z?n6)?MpT(B_?8 zUnDS*Lk0q2I#$D&TWdJX-n4FBo8|PY;qHW=aT^y;oCohm1QxcDh6vFU-5sY5tK;u2 zkGN_Uf2{j(m))F#@VSvT@9ALrVn{2~}O9t+GGS*uGtT|#S?A^MBTbihj2PpQk&&6`7%3+xC5#+&Eh5%C7 z&*g`nz!BV_>ZOXZ4+D;UBq_`c1!w%tp-9QNsO{gIDZ?)^HR2>|Ih1@pB84)cGNcVl z1sk>gnn9X9q3X>%J7^y;;TVvS_bz6yCvAJ~Hj22#q67pBFoRPZ)t;L{HM{}@7E=7z zBr9EQ@uqR}RL#Sx$(s9`cm5#oVpv|&3PhYA|G7+I8>_TgTx{!R`Y4m^(rG5&;>^5# z6Sj9SHCyvRw@;>bZh_sL$MT0Pz-OPCRau5BOVAP&OPtRqx1@>&vVNGqi9T!C&Oqo$ zYD>@0F8%IOqY;;+`&HI3k~ML%joLmJ-@PPM5+ax2Jxtw=?ZY!87i}5LM2Akz+uxdkLA z&v(q!fbIw$1hk97mg!4Vl|*BM$Hg@FK#E&Hzt$olRL{`lcc7G9ssA9eqHW+qK}CYp zusvw-_QFy3!xnBIuPsYR&qF-ADZnfCUB<>t2p5*{mr5zqWT2{lHd%YUJyS-qTA)Ep zM@*bx(8z`n=9dw6`bEI2PqD9OmX(;%Wi2Ahr}8pWEuq&^>Nl^m!1gOMj_2oU1%(TkCcI{W6 z9)`chHO7185YPgLo&QrE9+~=%kjSfWGV6dg*`Ci=X_ZHF-uQ{i6yK97QckSyo5G^D zs`i!DYk5dv0Y-=*ut)Kx3v>md3gbPr`5qY0zm(M);}>)NRw25|1(1OE9+)j#gsN}j zGJ${zV3uOFODefy%C8F7LSo0od^0!5G!YLCEgic8KWaagbI~?+Dsy?i>1v{i<(9n4 zTVn{biBb3Q&>ApROfk;9faO{I0R3LO^P*mSV78ZpxGYyk@t96(2%|C}R>NbRg#yPi zOj@=1n<&TH+(WY_ddy7kL%;lAQ&8#$tlk{CIFJz3HXJ8m_CH4X zGf6Qe8d?k6YgAxwEuImPf37)C-HY>k@-mJ3KvMNcMmBG1KxMfQ((QlmTWE`^XQ5-H(a*YNIxK;}}{K-kf@rH}E z1d)0GV3)Y&SUxd3dd}iM#Y8IY@Y@pyc#wWW;f{fSsW!0%%$|NupOKEv%Z~H(yZeVb zLu2i;Q#+oqMu!PLnaQ2JF(Ii_v4!@gXIVLasd{S|8r=J8-3>Emgl*hnkW26fr-t){ z*(H0go6H+zL2=E|tpjRR4Bc3r3kQl*QqJEEhLs$)ZY^74WH=F(8QMEJMMQbvW6{Sm zL@OEhr75v!GizgB-hw~>uC1(E-IOQb1wE3hx(LKWdG~asB|%q7FYO}0aK9wcAqK3b zm2^{icSX6r<%%Q|W6ntA7iKUHPrbGSIDTv@d8*rDp9roLs2W51TR6CdzI7hVwiZh< zo;XvleruvLh=BMa`+o?(WjfHev{^xiLUT)o?Mz6Pmg^^1@B8~MU=oO3Fw|D@Hg-Dz zFPu_|lm^7K$G;lBX64ymkY_|8zL*KOkY}rQyHi%|c+CEJT5qR`sDJK$_Ltu#LQi9~ zRc9O|v)J{mw;GG)(q;q+S8yZoqq-Xd{DL5GT9)PJ6!tw$kKx43dFSeim*ohtR2x74 zZMgPs^8&jRRB{I)CSGm@n;goq%fd|lP|7MGZiAC1eefCH-f$@+uKP-o2MxS?qEDsy znlrY=)7M##$J6bTi2Px7N2IeQDg=?-_-ijVH&>9dp9J%x5+tcolSJ7Jx zzP_irMjG)UjL&2WEn|<{v=F4x>X8;^0Y8X^)`H$K5o+_t6=Yp8V6+l)FX6kWyXCv6 zC3#)gGy5`?*e#coB1sR({V=cPa>JyJ>n@ol?d~MZP+@|HZ<$h zzOS92w){D(3}0bGorl@SrSn(2ouS_-UtP@@`GzukF%p*lg z2*(_01*0jfaZFS*rr2V_4abD3h^lee@ycqq& zoQjy&DHEts$gd(5wvml~`IO7hNGlean5~-Va7C@zo(QCEb#ygUkJ;2*ENbw6Z`UJf z6{~fYwva&s zZ12JalnC!9zZOwlxpnQM0M(V}ir2@?lW6$rsi+{&?`G>xMq<^aLEU|)f2q=*oHef# zKfe^_s=3r$-|zR>3vIXLqpoWM$J-d~u#<01=!Q5j9`dJ=ePgvi3T2EdF%o)98Xm}# z7|CCmMa}CWcNskU23)4pX1VX~mxR4%A^xS(a|1ewa&mEu__#d4-(_c>znSruY8E+L zzF?GMfA+QRXk4uDlO})cA27Y}zH3!TQxWj0o^FnYK3S+)XDzln^eo(`Y)<0855@z- zQ(`|htr71S`vJ~UduVkJE*Wcfk*Qy!8~;b32_##8U9*dNy`*$1pnTx!_C#B*%_GgS03dk3-Ve;aSY;3L7#I#i>TYh14Ia$h6;BGE9&Ksn^i1leb9uhgyA_VYM(|Xd*pgby{PZdFx=e z!JzKwWH^mB*SVA*k2?#ZY##c4@KId%9^_~YZ0sJW3_5h&Y*j%bGEyI9nU(26haNog z=P3MDnTB*er;$-`z5gH$Obpr_-mF|0ZzN?1!3By@amb%rrtrgYeV3EW%b4w($U*OX z>#bnL?UpJe8Sgps`}6jDkB)4s038W^>&n9v)gAIZ0i(DC%UD==h^5!)#P?N49icUM z8?0SSw?}q$6@Fy|SxO@Q$0{?xSXYnP@bNj-DD_2rJnN(TfPc?Xx#L(-Fhwd{z>6e@ zKrh=P)xqs>>ntB&$j&(Gabw(x;iRpi$)Rx7zO;;HS@Am1UX5_5dj=n;dc&p?yzil5+5 z!+ELowWC=(PrO~Sn^{-)>z!+(@P>1CL}r}3Fs7oQA@}0HpZFj93%hZQc%JnUeAW_nur@^pM505qfjfKnIne}79P@(K#{4UEfbg+6j zj&>sH@d`MS#X8GsqkasT@F-6)%gFnwZt=Gim=KDiH~KlOI_*{W1WsZt$J9Q|tdv1p z{f?l(iKAIjt595dD-$RYGtjwtv&nILvMn&?+v2lilXoM0Nn{n_eSM&9ZUGj zYGdDKv9RGdL!{k%hEnNi$wfOI*q3#axRQdpkEP-9bFtp^nsri(hzWpIjiaN2y`||_ zK%yg&jI#mY<6H607m0$JvvpucX87l)b?17u}V0*`!XU}v)gBdSNX=m9Dw(dsEt%oQ4?r@%{ zMeH$(4zrMKaJ8AdUjSjU`aD@3#IQqA`S`retyPmN_}GiB?ESe*5`tW3Veeqt1ZPbZ z&swbo2gLlP^6ohjR2T>ip=_dD`&;IkYWPLraz=&$qcN8|+jcbsrkzC6cx`%ek5^fY z_32jodPgqhyPH*z+s4DOmLlxn(754PN_ltJ^k1s|G(buctZfbLG77~tzEE4!pon-{ z6|+d_Vw4L2NzY60u!roFYc z3Gmq3Vqvx#Z-$&YfL`be-dg&}|1PP<$mV>#*6{;9fvMcsn$dnRbFeGjUG(S7DtdNA zm)x(QDzGRSV`9Oiz;jG^%Bh(qM(0rcN@V)ft}Ud_M?Q_6#>RnSDoQ=-Dj0H+tB3&z zu?{Uh+r;BfK$7D&82}vhg4!o;J8y!lPEV*8MsklsI6}&gbb_nleq$?+r^S3H1Js`D zr-trjt=*J^sg;9akExr$hS!X>61g58CxJA%RjiIMju!()@n< ztgIL%xm6_{g92A??^HAgX?zo93slp9Lx85Te>YaS-I>62S?7TEjFn-rA^7oNC#S=B zlVz9akO#b!ich&hXZf?X%`9nR##3u)v}7}bwfd@k$;A&&YEi!oXwxl5T>xy71I6ys zz_5>Q_2j~rfPmo9Fka)wM}y2RM>dqm#)bI~>WzGhEGsUAS3X9;$~L%)5IAj$->n*pjC?*l zA24}>%|BF4P5kpd{d@6XqP`k!gX`3hPT2&Oj{wN2P1|Co6d%bHSWU1 zje!0$J3?@Ik(ay5VmjASZc4KjanR*hQBF6=v0xD)GxDyF<>+^jeeAs`6Na?82oz`{ zKPR!6QJ8;-q*J0-S3fk65Xn?9W{Oj&?)2Vv8;!vwX|waPvoakCwnycNSZxR&b1Tq@wt7#h6I~*PF*}eC+;Jq9-b(2eoAPoQFUt^1CjOwXQldu(New9&_+uQug zJ54LaLM%K=Blhca-e&;GU2jNu%+#0PP8*Bz*7=by2Me}OAM66@RswUCRGO> znmGe_tgIA@A-42V$<(I}TjN5{(G%@Cco)}*e)6A&mxtHErsv2BNhJp+#d;c~=QsG_ zU}tw&sRFsho^{vbwwLl`i^OhDGC?M;YU9_j-0r-PUvX-#hT##guNeqA|A%-w5XxCg zZ}vkz^c7!0Q)P2^a|mk?C6aeHp82}C>g+Gmlfpm*0>F1XIy_QEK3Hhru?$dtakkTp zuD?gQkZ^?G1HP7U8jX`0GwK7coRRQrH~XY^FIAXu^fRee(6UE!Kp6Dq|GA_{6bY~y z3*y(ukPQm@|pSfswkBr!7Sn~5M46>484#ig; zsyKbZM&?e-&779`z`9MEgv-mGT0xLK^PB&kk?C;WW92Z4L-ECXxrJAGZ4H>UW_aQu zn)gpT^>>FDM@PMrQ9*f~hc4PY4H;|44H@(Oi+jp!V%#AQ|9qieAly4^u<$KfX@;TT zHs1pRO<=*pqoK_sPI*58U~QsK##Hw&$UiB(wb6zX^d~swJE4A(D z-^0=bxTV_h`5xrk=N4=NB7ye5ANJ$2l2R(FReIK|4Dz3X!}i>>_N@iu5-QU)`x_*V zw2#10xqhHSo9^ukr|%B%9tUQdq_kx$F=6BUSi0H){xA%U(<#x|Xm@H0rU0*0C)h-1 zAa>NhyS{Np&%kCLfc>czSMdu)>K&9ptZgvcz6Xur$yK2sK}>zrw|AzaII(`CH_ml0zi@W2uMkyz%1~_wSKA;G2`?Wc?(wTV zKeu3cSo+ISQbhf)N?=|EBI zyL<7m(*hQ9RVOO_?FF+ETX((v-zB0(6x=+Tn+5_G+talR5V1FjzXaxKZlMw%I-@yy#oU$Ro*${?>|Cdc7gHv0N=3dZuQ&0 zRJCUF#b+7i96oNi&iR%nOFAlf)|Agc1D_6V5!y3tTGF}TnhT-P2EpqrP(=IoZ{a$^ zEh+GA%hpRl1yJzI>pH3kB4xqqQ6B=9ssr~pk#YN##f!gVo!f8G*%&O-T1LbYrv?F> zh@3^AsmQ(v5>vbh0gX2h8#hQUc|i@ylw#JHJ$gr>2tIcRK}Uzi++9hJQbZgM36XWz zW#l^r&>N0_#;wSv?!cqCH1@k^0i&62@1VU?Cm80Mun9hB6<;`ETx143do(Mcx+EQ= z-h(RgyvCXX{O30HZ7bzDx!@(xUpEbjF-BK?@Y;b z?K90y%bXWcz!a|UjBPr6Acc9xj4mh(^(`AzWd^<`=eMh^#Wtq53ojiL9IA8g|HzqF z6Hfw4-v=A;MrO4`sq7f>crO<3n%tFIKC$0b4h$odI97(;yUI~LnR^U&-O9~A=404g z5!(4p``Y_#s_qxwUV9^YxXbQUwVFd^C&s157};N%iJiRHN6co*vf7&L#>rOC0?Rlf z<<+FCONutbhcy|{KrVQ63MY3bFz$Ig>@C9Wz1hQ@6|D}4av1P;GD5BC&0nLncha?c z;2swlrmOY?#n&M!GU8lpE$8ije4Utnd1ICM3{xh4rv>YgRyS;e!2$4D@cp=gm8QaD z-Kry|cFx77>bO)pbCk+os-Vx8jsBrkWZA&>n+vhlN~sM^j`kYeuT#!nG{Mj)()u{% z=U9L%F*j8G5-ZogR%oMaKO3r7bx4>mo%xf)Fwplg_411!9+N2wtUgAtV<$RZN1g@~ zPoZnEsarc>k2Q_N(vDW8j@{|3jd;ECBYRc15JtO(tuR}neTw19(-*X9gtDEji&)b7 z_dJV`4VKH+>Xqd4Ct`8+vKPMxQ@hS*} zb^YLPtmE{>A*knG)=sR@!qXJnw2Jl+e$kT&>;gI`TdT6nPXBt^?@sG?4XXpoUGAY7 zEAV10VV%?xr`(To+~>XHGWRvh57%zfJ7Xx5kA&1swE~$@Yj)8Y)Zfax#cu_BsALfRH*2l+CU5Q;3b27pe83HRB`by zRk<9sx!7K)Z5sAL0uWSzlJ+L0wvC@YK_?|GIo4{FjQ{;ZawqmT0v!WrIhMWAe;USZ zrzv~>#1Qz`M0WGj+$bwLQco?kp9GGlRE%Rf)8p@e2YV9m12Hp{qR8V{M0!D2;W>}0 zBd*~464pZ>vnby1V}Zfr(SxY1ME2&M%KTFReP{{kdx&ebgZ(kGrl5i2kLzNU!8vl%ow7 zqKxncZ0K3u)+D(!w<^X#RrHA zv=Cy?Dwra3BwOSLbw`lVOxXTttZ2T3tzxNfzM`l>W9IH-WO8hK@^496Yh<1^G zsX8>BXc5&y;pSsMqq@9*KDAtH59hoI6b~H~@vdZyn6{gsc(*-9Hn%khcqfnNSSYNX z6n6DFs0v}S$$?2tjK&6U;BlfF{a(awfS;)=BwoFi2y;bXC<@2qX!pCVa-Tf6*>6>d zuvwe;6#c0eZ@HWwYu)@_DZ8Co*|mezqman`YGM9AT7F~pkG0n|$@lD0f@NO6y;gRZ zw!7QOi8r}B>&&{Ia&U-xbryN}H}+(h@=hxqTFGB>{+N|*K?(2eAFMV|_*sBO&JqWJ zfZDG9p!`HEpbf~Naz@1vaHMmAvuEiaYsuCi;o|TG4LJ;>aUix?+U~oFn~UMNdlIe^TvHM9UbrOOz&i=b1SwCdA$U1mICb&G9=_0sWab3 z<0(d%d!+IG#W}ZlZfYkluZBCl#(JMGVV)cxugWH|@j5Ky1ctgrT>7K&f)*F&RefBP zIvfH&0IjJ1_A*R<4V5FD!}AiNoQ6L!cNesF*BwnBOqE^Ew9XV3q%{=+yEXd7cAk|Q z^V8R`Iq$9Ggo_+^IAWk#r(`PGXGiv&yV~H&do?L8(|`wuJ;ickhmCdP8m_H@c_y&B zH+A-&r_czNEVo5kLtsTds#((0RfaI}dkzw=(b$Mcn@UM_c%-4Y`tF$V4!K4W)7>R_8IP9P&yaXo7pEVuO6e?_^92 z=g72){b#ZeA?JpX&i<3_>ih8ubSMX!6x*MS>0+x5$2;Nr2A zLeFdgk#oiQ=j#5jnVLh*mx2B^R=5$}<%#Ie;PN%_R29fJJNfWVy4~FWGCgj-g-hnQ zaZ}DKKCB+(Bt_&lL27x(NWE|(7plYt2;l`3< zLs9)@+g;bz6hKPQ*JlF|w8=O*uG-@3D?c-NNxf$@Pvd}<5zgx?r4r~Ly6~Ngrhp)b zHSuuOF>~PzEn&d@WZ?a5b4U!$dPR3?hIgDkY`mzS`u+q(=?N#WiwDBzpcp;!9Nh~) zQah2D0zlx&DO>GsZAaB<;I-nrmMrfb$2oWVx;#!Cv!`Y`Zo`iN_yiDzfk@2bjapb` z#=TAvo-YYan?R5_6p}NEqR8&Cd%E z7WQWZq{|K=H&WxUQ&JO)3*b+$T=U!b&knItCyq9+BE93(u7xInIVkCzGmr6&`_ zP9PqK+=Y4%^Yx_(w+P!gGjJ*HYJn#00%VKy{)ZwNh;VYV4T2r(J@YB}M`9vK*|@S+ z`?8tRBP6aQ;~T1BFd<%Ao6sSEw%*tGIYEL2I+XikG5GM?KSE2jT}b2`;U0 z)QBGNmLo--d%g(h`vQC7Fujd&5?GseEki$H$+SRQcN zKu_Mh9l&J|YB%0SCE-e!@k#6pf*Wy5|3&Qm!c9{XxR7j;NFq() zGJXwEok?|$uxsJlzq}v@sb2@Qg8>bBV<6WCsy zq`ntgw87OXZMpr{KGu8GC}RW>biyU?z%qvcYtwq*U*mUHGkVQIoN;SpZHAxN)%d zw#KUzCr^F^l!8XLMwff*%K`^9n^#Wlj?OY^%ILJaZ|k32aNBWm^ckm(gO9bQixBIU zA4%I6|Docdac7&s0I8;uxhXK5sz$;&n>pa7plfx>WejQAN~6x+Ont2(nh40TO=Aa` zrs-^xgn2Asr4~=F8%lzdxP$>+;Me@N7!dDbjK(#SX99kbYtAempWXgS_0Qd;TSD`O zSGTXA?5TiC`^x`Le3$+2<2sJvt&pv~Kum_i|9#HBQhlVl=1lbu)n}o{R1UyhUf|mK z%dt}D!2cb_Nq#++oPS#|Pw{cuoo|m*{!dreAJjy4$G^L5GP?wpE+aPe97|UB>BN#{{6+f-`V?aJW$zM^yC`{{#E!D?5sU=Vsv=Q{EO0!_fH-h z;F9GFHE$j)ANp3=mgzi0PcWY8nhZYt!F$e*p{?DCxr4prhQYQqbIJR8r@^p&+8#*m zv^R*{jdu#((maTtY)vZ00IGyw_5p7_?+nV%1upC=z(T#D9Rc7`x}gi%u7VF`rQ1`>g7C+Lahe*cKIpn7bg z`XR{q=v>BwFLn|UKrpJLFR3)ur3%`!r?W&&yzsfMQd|jF>L?g~$@IPK%W@%fuFFUY z`FUE5rx5lKm$Bmf8lI?uR=ed+wOixJ{71?t=Fg4^MWa2gxT~>p9-I^&|g0y zjFW;7uOV16tBFCAy2svwATo;V?}tY+P(+>*c9E$U;NcJz2o0F-X#^`*Cu2-jfBJRu z4p37%?~d$zq_c2uRofq<#Pwl{WGFID5_&H!t7O9hiwI~co+UXolj-BxKEB+0U9DIU zz)Z_D2uMRsg4}~5Xm1dnBlhBKk;mp>+XmyIXlf2UtVdBeACS%{vHvsVLUO@am&ovS z)?Qsts~(i7%cPhg%+VB#_S-yNXh6uNW^iNx=`qnt6B@TC{PA+xfRS8D+2YaEkw(IE zREd*}do(lw;ETcz7Wyf&o((s<*jErbfR-(yKX=GGfb#jE^=kF~D!=%wAgk0vgC?{B zw6^t%_lzgJDS(;C06}ybn$jH+RvM-NQiz|f?IS(Zfs#GNnsJ&tc(%y9+IVyXcKRf4 z9@B}AJa#M$EYya9WY`$U8KC4ds33ABL(V{H>#dgM_NxQ?mzIsjZFkzyxlfjn3CI0f z@N22DF~t|EA#x^>z&tHruqq-cx;Rlp2j%9`zv2E$Rh5To+`gbW+F+O8zb_K3$T)bW zRhjMPv0=U92H6^lP6IhMXB8TKk{B8&XMrf|t20jo8~$lMzY&Wv>{9x5wg_iva{>ok zln2n!!}W-C{$JC@#;5=qYt*tQlx9Ei`qNxZ#3Q?<2ydIno(pSy#(q~z%Tzn*a<0#6 zmzJUTS`=!X8>-9mY-#AXcq#D-2z8o9TcDe zx;e03$Y&QgEv2JEA+$&&V)svuD_AwnA+9J-3euPWkOlHaV>DF?up$i+5?LkP@luFd zQ-tYHd07z9_tJX(Xg?+VN*g?8tz$hg}lm|C2?!wFMb4*M1k=&I06s4uzl@ zwJNWWQl+F`JVQ#c1dE5SlcWZQ_hF?Lc%=$tY90sTQyp^vAHET@HVh2VC4D$`q=s+u zU%w2)nLcnzB?)tw0Sbswm|F@DM8~$GOn94DGaQXVl@v$B z>58`Tb3G$)$AF)CHL1`|+t!i$_&3*wmCm!c`hQ2|c@${xp_USTZt-}7Xg}<54Jz8W z!w!e6^No1hHLW(c*sdb>$>Wi;ua=eR^R)X^dZKW$4lg#m>A<}uaQ$L>NH_4+@_zuL C7= approved_qty 时置为1 + is_finished TINYINT(1) NOT NULL DEFAULT 0 COMMENT '该物品是否已完成归还:0否 1是', + + remark VARCHAR(255) DEFAULT NULL COMMENT '行备注', + created_at INT(11) NOT NULL DEFAULT 0 COMMENT '创建时间时间戳', + updated_at INT(11) NOT NULL DEFAULT 0 COMMENT '更新时间时间戳', + + PRIMARY KEY (id), + KEY idx_req (requisition_id), + KEY idx_item (item_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物品领用申请明细表'; + + +-- 4. 审批记录表(同上一版,略微无改动) +DROP TABLE IF EXISTS inv_requisition_approve; +CREATE TABLE inv_requisition_approve ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + requisition_id BIGINT(20) UNSIGNED NOT NULL COMMENT '申请单ID', + node_type VARCHAR(32) NOT NULL COMMENT '审批节点类型:leader=直属上级,manager=物品管理员,other=其他', + approver_id BIGINT(20) UNSIGNED NOT NULL COMMENT '审批人用户ID', + approver_name VARCHAR(64) DEFAULT NULL COMMENT '审批人姓名快照', + action TINYINT(3) NOT NULL COMMENT '审批动作:1通过 2驳回', + comment VARCHAR(255) DEFAULT NULL COMMENT '审批意见', + action_at INT(11) NOT NULL DEFAULT 0 COMMENT '审批时间时间戳', + + created_at INT(11) NOT NULL DEFAULT 0 COMMENT '创建时间时间戳', + updated_at INT(11) NOT NULL DEFAULT 0 COMMENT '更新时间时间戳', + + PRIMARY KEY (id), + KEY idx_req (requisition_id), + KEY idx_node (node_type), + KEY idx_approver (approver_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物品领用申请审批记录表'; + + +-- 5. 申请单附件表(申请时上传) +DROP TABLE IF EXISTS inv_requisition_attachment; +CREATE TABLE inv_requisition_attachment ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + requisition_id BIGINT(20) UNSIGNED NOT NULL COMMENT '申请单ID', + file_name VARCHAR(255) NOT NULL COMMENT '文件原始名称', + file_path VARCHAR(255) NOT NULL COMMENT '文件存储路径/URL', + file_size INT(11) DEFAULT NULL COMMENT '文件大小(字节)', + created_at INT(11) NOT NULL DEFAULT 0 COMMENT '创建时间时间戳', + updated_at INT(11) NOT NULL DEFAULT 0 COMMENT '更新时间时间戳', + + PRIMARY KEY (id), + KEY idx_req (requisition_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物品领用申请附件表'; + + +/* ================= 归 还 相 关 表 ================= */ + +-- 6. 归还记录表:一次归还动作(可归还一个明细的一部分) +DROP TABLE IF EXISTS inv_requisition_item_return; +CREATE TABLE inv_requisition_item_return ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + requisition_id BIGINT(20) UNSIGNED NOT NULL COMMENT '申请单ID', + requisition_item_id BIGINT(20) UNSIGNED NOT NULL COMMENT '申请单明细ID', + item_id BIGINT(20) UNSIGNED NOT NULL COMMENT '物品ID', + + return_qty INT(11) NOT NULL DEFAULT 0 COMMENT '本次归还数量', + handler_id BIGINT(20) UNSIGNED NOT NULL COMMENT '办理归还的人(通常是物品管理员)', + handler_name VARCHAR(64) DEFAULT NULL COMMENT '办理人姓名快照', + + return_status TINYINT(3) NOT NULL DEFAULT 1 COMMENT '归还状态:1正常归还 2损坏 3其他', + remark VARCHAR(255) DEFAULT NULL COMMENT '归还备注', + + return_at INT(11) NOT NULL DEFAULT 0 COMMENT '归还时间时间戳', + created_at INT(11) NOT NULL DEFAULT 0 COMMENT '创建时间时间戳', + updated_at INT(11) NOT NULL DEFAULT 0 COMMENT '更新时间时间戳', + + PRIMARY KEY (id), + KEY idx_req (requisition_id), + KEY idx_req_item (requisition_item_id), + KEY idx_item (item_id), + KEY idx_handler (handler_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物品归还记录表(支持多次、部分归还)'; + + +-- 7. 归还附件表:归还时拍照留底 +DROP TABLE IF EXISTS inv_requisition_item_return_attachment; +CREATE TABLE inv_requisition_item_return_attachment ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + return_id BIGINT(20) UNSIGNED NOT NULL COMMENT '归还记录ID', + file_name VARCHAR(255) NOT NULL COMMENT '文件原始名称', + file_path VARCHAR(255) NOT NULL COMMENT '文件存储路径/URL(通常为照片)', + file_size INT(11) DEFAULT NULL COMMENT '文件大小(字节)', + created_at INT(11) NOT NULL DEFAULT 0 COMMENT '创建时间时间戳', + updated_at INT(11) NOT NULL DEFAULT 0 COMMENT '更新时间时间戳', + + PRIMARY KEY (id), + KEY idx_return (return_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物品归还记录附件表(归还拍照留底)'; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..510b3af5a6ae14dda395a38622cb49dd656e7c40 GIT binary patch literal 15406 zcmeHN2~dxxGr$*!P+W>J)^3Y!`cc&U9q2qNdqFf)nS>8iiF|L*s` z_xk;>|G)o!j}SXDg$M)$YZ=+(K*(%Dh)mXhKh22{4fgr_KOBFSkW*qprXdZoAV+^c zR{wW}v+hJTb9q&QsT1eF+jNby`E;hK{KAyx|L~K~s7>K#&M%CS?D-yq+rOEbTJgeDY5Fj+qQF;oD!w zKUlFPp%k4Uspj{>bv1vW+N>8#!vqOc^;CO;VmC1Ew7M!8d0rp5$9iyVNZ;5^&omaH zu3vDW9*7LDd3aIpCtI;uz-9sSS%5i9;mruoQPKmHub2><Y6aWa{zYGrk%6aBMH59`kNF6KQaRuB8e*~Az?|~Mi+-xZC zU1bOab?w~AeI3@k{ifH|@e-G^= zL25o_2ak^%DIIbi8#CdCx45E#8&_64Ts8&OC;zpY))BDiubhQtU-A~0H;ouGx}$sw zvW&W}h1C2T@&9(KHX z%MP`0ww&o@$5-8<2PsyYPMmfNT=MZOJL|u|UQl*uAUF|s)*J!%q96O>7!pkva1)L;lfW#W zNx)i%70)>tVYx>o>8;>V*aL1FJ-F@d1^&jmE{6bBly#IaS&AJkP_@BnV{NCj48S$@ z8VKJwi*XzP=j|9vi_oN)jBEu)+%p7nmsd7QwH@G9+y#QQNAIzJRjBtU?gqDvHekmT zUm&FK%+w0n7$E}lUFiPmcurh-gJ;Hd@ZQ%0E^nRfVua_V(t}h7B|DqIs}$ons}A=# z_$nfeK(;t3eU^F_BP6?0m{`@}UDN?S`yYUF@(Eys=2j703p8Iv7{&xaR)K3_XCp8I z)m5m}sAtv|SN5R*TCg&}FlU3nc4`?RNjZFx;woXGq}SHQE3Qy6sKWD z>JEB*as{3~(1WptvUXg@2IXa0WuW*U$p8 z*ARC#d#G$$fGQZ{0d3x8dhpzRrJFS;FLOA}Pw>fY8qSL$RE(l&d+)fWst-#Sy*3AHqC2F}nezTVG zm+otkVoX=O>G*wSkZQ+JA7bEs4dbjD$0%(nekUH~p<)aa=a3@$ZB3W-)2qN+QF(*z zOXX_y=Pg)5+u@j0P6j8gv~{Yk9YpJE^o-f+G$1pIb zgchRSOwEB>4~TV}?x%bSX)OL6Xn~3_{));LS@sPO|L)5l=z;1#jds3+@6@%Zdx!f6 z7xhfs*FB~`b%S%D2dm|xO~*T*%56jc+kc%AoHKs}otZn>bG4^EQ52&`I7R__RzI6D*NbTC3xnD4EojU|T` zoRjD}k^U5OkeWsdRwZISLkDxVx?n6kGxnp8KHA@mg-hu#F+~3XfW9+E>#gAdv18T! z{!@?s^Yz&NGZypDpr7uqTFPHm*)CXhu+vgGWR{&KzF+(mt7!qsb+}H=ocCl|&28v5 V3)n1Rvw+P4HVfD+@XNNq{{Zc37lr@; literal 0 HcmV?d00001 diff --git a/public/mzl.html b/public/mzl.html new file mode 100644 index 0000000..9eb4e4b --- /dev/null +++ b/public/mzl.html @@ -0,0 +1,64 @@ + + + + + Code + + + +
    + +
    +

    🌞 hi~

    +

        愿你代码如诗,逻辑清晰,Bug 自动远离!

    +

            记得适当休息,喝杯好茶或咖啡,为灵感续航 ☕

    +

                加油,永远相信美好的事情即将发生。 ✨

    +
    + + + + \ No newline at end of file diff --git a/resource/translations/ar/messages.php b/resource/translations/ar/messages.php new file mode 100644 index 0000000..ed99240 --- /dev/null +++ b/resource/translations/ar/messages.php @@ -0,0 +1,37 @@ + 'الدولة لا يمكن تعديلها عكسيا', + 'success' => ' النجاح', + 'fail' => ' الفشل', + 'login_success' => ' تم تسجيل الدخول بنجاح', + 'password_error' => ' كلمة المرور غير صحيحة', + 'user_none' => ' المستخدم غير موجود', + 'user_id' => ' معرف المستخدم', + 'user_login' => ' الرجاء تسجيل الدخول أولاً', + 'not_null' => ' لا يمكن أن يكون فارغًا', + 'code_error' => ' رمز التحقق غير صحيح', + 'format_error' => ' تنسيق غير صحيح', + 'email_repeat' => ' البريد الإلكتروني مكرر', + 'email_error' => ' تنسيق البريد الإلكتروني غير صحيح', + 'save_success' => ' تم الحفظ بنجاح', + 'save_fail' => ' فشل الحفظ', + 'register_success' => ' تم التسجيل بنجاح', + 'register_fail' => ' فشل التسجيل', + 'language_success' => ' تم تغيير اللغة بنجاح', + 'language_fail' => ' فشل تغيير اللغة', + 'param_error' => ' خطأ في المعلمات', + 'required_not_null' => ' الحقول المطلوبة لا يمكن أن تكون فارغة', + 'picture_not_null' => ' لا يمكن أن تكون الصورة فارغة', + 'follow_not_null' => ' الصورة و متابعة المحتوى لا يمكن أن تكون فارغة في نفس الوقت', + 'contacts_not_null' => ' لا يمكن أن يكون جهة الاتصال أو البريد الإلكتروني أو الهاتف فارغين جميعًا في نفس الوقت', + 'car_type_not_null' => ' لا يمكن أن تكون التفضيلات فارغة', + 'channel_not_null' => ' لا يمكن أن تكون القناة فارغة', + 'email_consistent' => ' البريد الإلكتروني غير متطابق', + 'sign_not_null' => ' لم يتم تعيين الشركة التابعة', + 'password_short' => ' لا يمكن أن تكون كلمة المرور أقل من 6 أحرف', + 'id_empty' => ' لا يمكن أن يكون المعرف فارغًا', + 'customer_id' => ' معرف العميل', + 'tag_group_id' => ' معرف مجموعة العلامات', + 'claim_fail' => ' لا يمكن استلام العملاء الذين لديهم مالك بالفعل', +]; \ No newline at end of file diff --git a/resource/translations/en/messages.php b/resource/translations/en/messages.php new file mode 100644 index 0000000..4c73bcd --- /dev/null +++ b/resource/translations/en/messages.php @@ -0,0 +1,37 @@ + 'The status cannot be reversed for modification', + 'success' => ' success', + 'fail' => ' fail', + 'login_success' => ' login success', + 'password_error' => ' password error', + 'user_none' => ' user none', + 'user_id' => ' user ID', + 'user_login' => ' Please log in first', + 'not_null' => ' not null', + 'code_error' => ' code error', + 'format_error' => ' format error', + 'email_repeat' => ' email repeat', + 'email_error' => ' Email format error', + 'save_success' => ' save success', + 'save_fail' => ' save fail', + 'register_success' => ' register success', + 'register_fail' => ' register fail', + 'language_success' => ' Successfully switched languages', + 'language_fail' => ' Language switching failed', + 'param_error' => ' param error', + 'required_not_null' => ' required not_ ull', + 'picture_not_null' => ' picture not null', + 'follow_not_null' => ' The image and follow-up content cannot be empty at the same time', + 'contacts_not_null' => ' Contact person, email, and phone cannot be empty at the same time', + 'car_type_not_null' => ' preference_not_null', + 'channel_not_null' => ' channel not null', + 'email_consistent' => ' Inconsistent email addresses', + 'sign_not_null' => ' No affiliated company has been set', + 'password_short' => ' Password cannot be less than 6 characters', + 'id_empty' => ' ID cannot be empty', + 'customer_id' => ' customer ID', + 'tag_group_id' => ' tag group ID', + 'claim_fail' => ' Cannot claim ownership of customers', +]; \ No newline at end of file diff --git a/resource/translations/ru/messages.php b/resource/translations/ru/messages.php new file mode 100644 index 0000000..b275ac6 --- /dev/null +++ b/resource/translations/ru/messages.php @@ -0,0 +1,37 @@ + 'Статус не может быть изменен в обратном направлении', + 'success' => ' Успех', + 'fail' => ' Неудача', + 'login_success' => ' Регистрация завершена', + 'password_error' => ' Ошибка пароля', + 'user_none' => ' Пользователь не существует', + 'user_id' => ' Идентификатор пользователя', + 'user_login' => ' Пожалуйста, войдите.', + 'not_null' => ' Нельзя быть пустым', + 'code_error' => ' Ошибка кода проверки', + 'format_error' => ' Ошибка формата', + 'email_repeat' => ' Почтовый ящик повторяется', + 'email_error' => ' Ошибка формата почтового ящика', + 'save_success' => ' Сохранить успешно', + 'save_fail' => ' Ошибка сохранения', + 'register_success' => ' Регистрация успешно', + 'register_fail' => ' Ошибка регистрации', + 'language_success' => ' Переключить язык успешно', + 'language_fail' => ' Ошибка переключения языка', + 'param_error' => ' Ошибка параметра', + 'required_not_null' => ' Обязательное заполнение не может быть пустым.', + 'picture_not_null' => ' Фотографии не могут быть пустыми', + 'follow_not_null' => ' Изображение и последующий контент не могут быть пустыми одновременно.X', + 'contacts_not_null' => ' Контакты, почтовый ящик, телефон не может быть одновременно пустым', + 'car_type_not_null' => ' Предпочтения не могут быть пустыми', + 'channel_not_null' => ' Каналы не могут быть пустыми', + 'email_consistent' => ' Почтовый ящик не совпадает', + 'sign_not_null' => ' Компания не создана', + 'password_short' => ' Пароль не может быть меньше 6.', + 'id_empty' => ' ID не может быть пустым', + 'customer_id' => ' Идентификатор клиента', + 'tag_group_id' => ' Идентификатор группы меток', + 'claim_fail' => ' Невозможность получения принадлежащего клиенту', +]; \ No newline at end of file diff --git a/resource/translations/zh/messages.php b/resource/translations/zh/messages.php new file mode 100644 index 0000000..b8ca32d --- /dev/null +++ b/resource/translations/zh/messages.php @@ -0,0 +1,37 @@ + '状态不能逆向修改', + 'success' => ' 成功', + 'fail' => ' 失败', + 'login_success' => ' 登录成功', + 'password_error' => ' 密码错误', + 'user_none' => ' 用户不存在', + 'user_id' => ' 用户ID', + 'user_login' => ' 请先登录', + 'not_null' => ' 不能为空', + 'code_error' => ' 验证码错误', + 'format_error' => ' 格式错误', + 'email_repeat' => ' 邮箱重复', + 'email_error' => ' 邮箱格式错误', + 'save_success' => ' 保存成功', + 'save_fail' => ' 保存失败', + 'register_success' => ' 注册成功', + 'register_fail' => ' 注册失败', + 'language_success' => ' 切换语言成功', + 'language_fail' => ' 切换语言失败', + 'param_error' => ' 参数错误', + 'required_not_null' => ' 必填项不能为空', + 'picture_not_null' => ' 图片不能为空', + 'follow_not_null' => ' 图片与跟进内容不能同时为空', + 'contacts_not_null' => ' 联系人,邮箱,电话不能同时为空', + 'car_type_not_null' => ' 偏好不能为空', + 'channel_not_null' => ' 渠道不能为空', + 'email_consistent' => ' 邮箱不一致', + 'sign_not_null' => ' 未设置所属公司', + 'password_short' => ' 密码不能少于6位', + 'id_empty' => ' id不能为空', + 'customer_id' => ' 客户ID', + 'tag_group_id' => ' 标签组ID', + 'claim_fail' => ' 不能领取有归属客户', +]; \ No newline at end of file diff --git a/support/Request.php b/support/Request.php new file mode 100644 index 0000000..e3f6ac3 --- /dev/null +++ b/support/Request.php @@ -0,0 +1,24 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +namespace support; + +/** + * Class Request + * @package support + */ +class Request extends \Webman\Http\Request +{ + +} \ No newline at end of file diff --git a/support/Response.php b/support/Response.php new file mode 100644 index 0000000..9bc4e1e --- /dev/null +++ b/support/Response.php @@ -0,0 +1,24 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +namespace support; + +/** + * Class Response + * @package support + */ +class Response extends \Webman\Http\Response +{ + +} \ No newline at end of file diff --git a/support/bootstrap.php b/support/bootstrap.php new file mode 100644 index 0000000..d913def --- /dev/null +++ b/support/bootstrap.php @@ -0,0 +1,139 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use Dotenv\Dotenv; +use support\Log; +use Webman\Bootstrap; +use Webman\Config; +use Webman\Middleware; +use Webman\Route; +use Webman\Util; +use Workerman\Events\Select; +use Workerman\Worker; + +$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); + } +}); + +if ($worker) { + register_shutdown_function(function ($startTime) { + if (time() - $startTime <= 0.1) { + sleep(1); + } + }, time()); +} + +if (class_exists('Dotenv\Dotenv') && file_exists(base_path(false) . '/.env')) { + if (method_exists('Dotenv\Dotenv', 'createUnsafeMutable')) { + Dotenv::createUnsafeMutable(base_path(false))->load(); + } else { + Dotenv::createMutable(base_path(false))->load(); + } +} + +Config::clear(); +support\App::loadAllConfig(['route']); +if ($timezone = config('app.default_timezone')) { + date_default_timezone_set($timezone); +} + +foreach (config('autoload.files', []) as $file) { + include_once $file; +} +foreach (config('plugin', []) as $firm => $projects) { + foreach ($projects as $name => $project) { + if (!is_array($project)) { + continue; + } + foreach ($project['autoload']['files'] ?? [] as $file) { + include_once $file; + } + } + foreach ($projects['autoload']['files'] ?? [] as $file) { + include_once $file; + } +} + +Middleware::load(config('middleware', [])); +foreach (config('plugin', []) as $firm => $projects) { + foreach ($projects as $name => $project) { + if (!is_array($project) || $name === 'static') { + continue; + } + Middleware::load($project['middleware'] ?? []); + } + Middleware::load($projects['middleware'] ?? [], $firm); + if ($staticMiddlewares = config("plugin.$firm.static.middleware")) { + Middleware::load(['__static__' => $staticMiddlewares], $firm); + } +} +Middleware::load(['__static__' => config('static.middleware', [])]); + +foreach (config('bootstrap', []) as $className) { + if (!class_exists($className)) { + $log = "Warning: Class $className setting in config/bootstrap.php not found\r\n"; + echo $log; + Log::error($log); + continue; + } + /** @var Bootstrap $className */ + $className::start($worker); +} + +foreach (config('plugin', []) as $firm => $projects) { + foreach ($projects as $name => $project) { + if (!is_array($project)) { + continue; + } + foreach ($project['bootstrap'] ?? [] as $className) { + if (!class_exists($className)) { + $log = "Warning: Class $className setting in config/plugin/$firm/$name/bootstrap.php not found\r\n"; + echo $log; + Log::error($log); + continue; + } + /** @var Bootstrap $className */ + $className::start($worker); + } + } + foreach ($projects['bootstrap'] ?? [] as $className) { + /** @var string $className */ + if (!class_exists($className)) { + $log = "Warning: Class $className setting in plugin/$firm/config/bootstrap.php not found\r\n"; + echo $log; + Log::error($log); + continue; + } + /** @var Bootstrap $className */ + $className::start($worker); + } +} + +$directory = base_path() . '/plugin'; +$paths = [config_path()]; +foreach (Util::scanDir($directory) as $path) { + if (is_dir($path = "$path/config")) { + $paths[] = $path; + } +} +Route::load($paths); + diff --git a/windows.bat b/windows.bat new file mode 100644 index 0000000..f07ce53 --- /dev/null +++ b/windows.bat @@ -0,0 +1,3 @@ +CHCP 65001 +php windows.php +pause \ No newline at end of file