From 8a0ef099271db16c2d3560e8175174c1e167230e Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Thu, 9 Oct 2025 17:54:29 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/crmAdmin/service/CustomerService.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/crmAdmin/service/CustomerService.php b/app/crmAdmin/service/CustomerService.php index 56d42f1..98d4259 100644 --- a/app/crmAdmin/service/CustomerService.php +++ b/app/crmAdmin/service/CustomerService.php @@ -135,6 +135,7 @@ class CustomerService $customer = $query->orderBy('id', 'desc')->paginate($perPage, ['*'], 'page', $page); } else { $customer = $query->orderBy('id', 'desc')->get(); + return $customer; } if (!$export) { @@ -176,7 +177,7 @@ class CustomerService public function export($params) { - if(empty($params['user_id']) && empty($params['tag_id'])){ + if(empty($params['user_id']) && empty($params['tag_id']) && empty($params['channel_id'])){ $params['user_id'] = 0; } $customers = $this->search($params, true); @@ -198,6 +199,7 @@ class CustomerService 'I1' => '描述', 'J1' => '所属人', 'K1' => '录入人', + 'L1' => '录入时间', ]; // 设置表头样式 @@ -247,6 +249,7 @@ class CustomerService $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, $customerData['created_at']->toDateTimeString()); $row++; } From 3a7449e2e9a162040488a2b2667fcccd18896bea Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Fri, 10 Oct 2025 09:21:20 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E5=AE=A2=E6=88=B7=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=AF=A6=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/crmAdmin/service/CustomerService.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/crmAdmin/service/CustomerService.php b/app/crmAdmin/service/CustomerService.php index 98d4259..0ba9a31 100644 --- a/app/crmAdmin/service/CustomerService.php +++ b/app/crmAdmin/service/CustomerService.php @@ -199,7 +199,8 @@ class CustomerService 'I1' => '描述', 'J1' => '所属人', 'K1' => '录入人', - 'L1' => '录入时间', + 'L1' => '详情', + 'M1' => '录入时间', ]; // 设置表头样式 @@ -229,7 +230,7 @@ class CustomerService ], ]; - $sheet->getStyle('A1:K1')->applyFromArray($headerStyle); + $sheet->getStyle('A1:M1')->applyFromArray($headerStyle); // 填充数据 $row = 2; @@ -249,7 +250,8 @@ class CustomerService $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, $customerData['created_at']->toDateTimeString()); + $sheet->setCellValue('L' . $row, strip_tags($customerData['desc_collect'])); + $sheet->setCellValue('M' . $row, $customerData['created_at']->toDateTimeString()); $row++; } From 89d633389cbe6d378a0e98b4b9a7af31a4cd2991 Mon Sep 17 00:00:00 2001 From: mzl Date: Sun, 12 Oct 2025 17:10:14 +0800 Subject: [PATCH 3/7] Merge branch 'develop' --- .../controller/ApprovalRecordsController.php | 4 +- .../controller/StockOrdersController.php | 2 +- app/admin/controller/UserGroupController.php | 61 + .../controller/UserGroupMembersController.php | 33 + app/admin/service/BrandSeriesService.php | 250 ++- app/admin/service/BrandService.php | 80 +- app/common/controller/BrandController.php | 90 + .../controller/BrandSeriesController.php | 85 + app/common/controller/PresetController.php | 23 + app/common/controller/SysPortController.php | 53 + .../controller/VehVehicleSkuController.php | 18 + app/common/logic/UserGroupLogic.php | 79 + app/common/logic/UserGroupMembersLogic.php | 22 + app/common/logic/VehVehicleSkuLogic.php | 5 + app/common/model/ApprovalInstances.php | 3 +- app/common/model/StockOrders.php | 4 + app/common/model/UserGroup.php | 56 + app/common/model/UserGroupMembers.php | 60 + app/common/service/ApprovalRecordsService.php | 9 + app/common/service/CrawlService.php | 76 + app/common/service/MenuBadgeService.php | 178 ++ .../service/MessageNotificationsService.php | 2 +- app/common/service/StockOrdersService.php | 38 + .../service/UserGroupMembersService.php | 33 + app/common/service/UserGroupService.php | 124 ++ app/common/service/VehVehicleSkuService.php | 54 + app/common/utils/WsEvents.php | 109 +- .../approval/service/ApprovalFlowService.php | 773 +++++--- .../service/ApprovalFlowService_20251009.php | 1738 +++++++++++++++++ .../validate/UserGroupMembersValidate.php | 29 + app/common/validate/UserGroupValidate.php | 29 + app/controller/IndexController.php | 69 +- app/crm/controller/StockOrdersController.php | 28 +- app/crm/service/StockOrdersService.php | 14 +- app/process/Task.php | 19 +- ...mUpdate.php => InquiryToCrmUpdate_bak.php} | 2 +- ...{InquiryToCrm.php => InquiryToCrm_bak.php} | 2 +- app/queue/redis/WsMsg.php | 68 + composer.json | 3 +- composer.lock | 157 +- .../plugin/webman/gateway-worker/process.php | 4 +- config/route.php | 50 +- plugin/webman/gateway/Events.php | 1 + 43 files changed, 4173 insertions(+), 364 deletions(-) create mode 100644 app/admin/controller/UserGroupController.php create mode 100644 app/admin/controller/UserGroupMembersController.php create mode 100644 app/common/controller/BrandController.php create mode 100644 app/common/controller/BrandSeriesController.php create mode 100644 app/common/controller/SysPortController.php create mode 100644 app/common/logic/UserGroupLogic.php create mode 100644 app/common/logic/UserGroupMembersLogic.php create mode 100644 app/common/model/UserGroup.php create mode 100644 app/common/model/UserGroupMembers.php create mode 100644 app/common/service/CrawlService.php create mode 100644 app/common/service/MenuBadgeService.php create mode 100644 app/common/service/UserGroupMembersService.php create mode 100644 app/common/service/UserGroupService.php create mode 100644 app/common/utils/approval/service/ApprovalFlowService_20251009.php create mode 100644 app/common/validate/UserGroupMembersValidate.php create mode 100644 app/common/validate/UserGroupValidate.php rename app/queue/redis/{InquiryToCrmUpdate.php => InquiryToCrmUpdate_bak.php} (98%) rename app/queue/redis/{InquiryToCrm.php => InquiryToCrm_bak.php} (98%) create mode 100644 app/queue/redis/WsMsg.php diff --git a/app/admin/controller/ApprovalRecordsController.php b/app/admin/controller/ApprovalRecordsController.php index aebbaa4..d2f4b06 100644 --- a/app/admin/controller/ApprovalRecordsController.php +++ b/app/admin/controller/ApprovalRecordsController.php @@ -36,7 +36,7 @@ class ApprovalRecordsController extends BaseController 'biz_id' => $record['instance']['biz_id'], 'biz_desc' => '备货单【'.$record['instance']['bizInfo']['order_no'].'】', - 'biz_type' => $record['instance']['flow_type'], + 'biz_type' => $record['instance']['biz_type'], 'initiator_name' => $record['instance']['initiator']['name'], 'initiator_avatar' => $record['instance']['initiator']['avatar'], @@ -113,7 +113,7 @@ class ApprovalRecordsController extends BaseController 'biz_id' => $record['instance']['biz_id'], 'biz_desc' => '业务单号【'.$record['instance']['bizInfo']['order_no'].'】', - 'biz_type' => $record['instance']['flow_type'], + 'biz_type' => $record['instance']['biz_type'], 'flow_type' => $record['instance']['flow_type'], 'initiator_name' => $record['instance']['initiator']['name'], diff --git a/app/admin/controller/StockOrdersController.php b/app/admin/controller/StockOrdersController.php index 8383f52..1d854b1 100644 --- a/app/admin/controller/StockOrdersController.php +++ b/app/admin/controller/StockOrdersController.php @@ -38,7 +38,7 @@ class StockOrdersController extends BaseController if (isset($data['id'])) { $appro = new \app\common\utils\approval\service\ApprovalFlowService(); $appro = $appro->getBriefStatusByBiz($data['id'], null, 'name'); - $map = ['processing'=>'审批中','pending'=>'审批中','approved'=>'已通过','rejected'=>'已拒绝','revoked'=>'已撤回']; + $map = ['processing'=>'审批中','pending'=>'审批中','approved'=>'已通过','rejected'=>'已拒绝','revoked'=>'已撤回','voided'=>'已作废']; if ($appro) { $data['appro_status'] = [ 'status' => $appro['status'], diff --git a/app/admin/controller/UserGroupController.php b/app/admin/controller/UserGroupController.php new file mode 100644 index 0000000..709c6a5 --- /dev/null +++ b/app/admin/controller/UserGroupController.php @@ -0,0 +1,61 @@ +service = new UserGroupService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['records']) && count($data['records']) > 0) { + foreach ($data['records'] as &$record) { + $result = [ + 'id' => $record['id'], + 'title' => $record['title'], + 'scenario_code' => $record['scenario_code'], + 'leader_id' => 0, + 'leader_name' => '', + 'sort' => $record['sort'], + 'status' => $record['status'], + 'created_at' => date('Y-m-d H:i:s', $record['created_at']), + 'updated_at' => date('Y-m-d H:i:s', $record['updated_at']) + ]; + if (isset($record['groupMembers']) && count($record['groupMembers']) > 0) { + foreach ($record['groupMembers'] as $member) { + if ($member['user'] !== null) { + $result['user'][] = [ + 'id' => $member['user']['id'], + 'name' => $member['user']['name'], + 'is_leader' => $member['is_leader'], + ]; + if ($member['is_leader'] === 1) { + $result['leader_id'] = $member['user']['id']; + $result['leader_name'] = $member['user']['name']; + } + } + } + } + $record = $result; + } + unset($record); + } + return $data; + } +} \ No newline at end of file diff --git a/app/admin/controller/UserGroupMembersController.php b/app/admin/controller/UserGroupMembersController.php new file mode 100644 index 0000000..4663bba --- /dev/null +++ b/app/admin/controller/UserGroupMembersController.php @@ -0,0 +1,33 @@ +service = new UserGroupMembersService(); + } + + /** + * 列表页数据格式化 + * + * @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/admin/service/BrandSeriesService.php b/app/admin/service/BrandSeriesService.php index bc9d11b..b4fdf31 100644 --- a/app/admin/service/BrandSeriesService.php +++ b/app/admin/service/BrandSeriesService.php @@ -3,10 +3,20 @@ namespace app\admin\service; use app\admin\validate\BrandSeriesValidate; +use app\common\logic\Fingerprint; +use app\common\logic\ModelNaming; +use app\common\logic\SkuCodeBuilder; use app\common\model\Brand; +use app\common\model\VehVehicleSku; +use app\common\service\CrawlService; +use app\common\service\VehVehicleSkuService; use app\common\traits\crud\CrudServiceTrait; use app\common\model\BrandSeries; +use app\common\utils\CrudUtil; +use GuzzleHttp\Client; +use support\Log; use support\Request; +use Webman\RedisQueue\Redis; class BrandSeriesService { @@ -28,7 +38,7 @@ class BrandSeriesService */ public function getValidator() { - return new $this->validator; + return new $this->validator; } /** @@ -50,13 +60,13 @@ class BrandSeriesService // ====== ====== 筛选 ====== ====== $config['where']['brand_id'] = $p['brand_id']; // 状态 - if (isset($p['status']) && in_array($p['status'], [0,1])) { + if (isset($p['status']) && in_array($p['status'], [0, 1])) { $config['where']['status'] = $p['status']; } // 关键字 if (isset($p['keyword']) && $p['keyword']) { $k = '%' . $p['keyword'] . '%'; - foreach (['series_code','series_name'] as $col) { + foreach (['series_code', 'series_name'] as $col) { $config['orWhere'][] = [$col, 'like', $k]; } } @@ -92,7 +102,7 @@ class BrandSeriesService $maxAttempts = 10; // 防止无限循环 $brand_code = Brand::where('id', $brand_id)->value('brand_code'); - $brand_code = $brand_code??'BS'; + $brand_code = $brand_code ?? 'BS'; for ($attempt = 0; $attempt < $maxAttempts; $attempt++) { // 生成后5位随机字符 $suffix = ''; @@ -123,4 +133,236 @@ class BrandSeriesService $data['series_code'] = $this->generateUniqueModificationCode($brand_id); return $data; } + + /** + * 抓取车型数据(通过接口) + * @param Request $request + * @return array + * @throws \Exception + */ + public function crawlModel(Request $request): array + { + $series_id = $request->get('brand_series_id'); + + // 验证品牌是否存在 + $series = BrandSeries::where('id', $series_id)->first(); + if (!$series) { + throw new \Exception("系列ID {$series_id} 不存在"); + } + + // 获取品牌信息 + $series = BrandSeries::where('id', $series_id)->first(); + if (!$series) { + throw new \Exception("系列ID {$series_id} 不存在"); + } + + // 获取品牌信息 + $brand = Brand::where('id', $series->brand_id)->first(); + + // 执行抓取 + $crawlService = new CrawlService(); + $href = $crawlService->modelSearch($brand->brand_name . ' ' . $series->series_name); + $seriesId = $this->extractSeriesId($href); + + if (empty($seriesId)) { + throw new \Exception('抓取失败,未找到该系列车型'); + } + + $client = new Client([ + 'verify' => false, // 禁用 SSL 验证 + 'timeout' => 10 + ]); + try { + $response = $client->get('https://www.dongchedi.com/motor/pc/car/series/car_list', [ + 'query' => ['series_id' => $seriesId], + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + ] + ]); + $data = json_decode($response->getBody()->getContents(), true); + if($data['status'] != 0 || (!isset($data['data']) && !isset($data['data']['tab_list']))){ + Log::error('抓取车型数据失败' . $data['message'], $data); + throw new \Exception('抓取车型数据失败' . $data['message']); + } + $models = []; + foreach ($data['data']['tab_list'] as $item) { + if($item['tab_text'] == '在售' || $item['tab_text'] == '停售'){ + unset($item['data'][0]); + $models = array_merge($models, $item['data']); + } + } + foreach ($models as $key => $model) { + if(!isset($model["info"]['car_name']) || $model['type'] != "1115"){ + unset($models[$key]); + continue; + } + $official_price = 0; + if(!isset($model["info"]['official_price'])){ + $official_price = (int)(str_replace("万", "", $model["info"]['official_price_str']) * 10000) ?? 0; + }else{ + $official_price = $model["info"]['official_price'] * 10000; + } + $models[$key] = [ + 'title' => $model["info"]['year'] . '款 ' .$model["info"]['car_name'], + 'brand_name' => $model["info"]['brand_name'], + 'series_name' => $model["info"]['series_name'], + 'year' => $model["info"]['year'], + 'msrp' => $official_price, + 'car_id' => $model["info"]['id'], + ]; + } + return array_values($models); + } catch (\Exception $e) { + throw new \Exception($e->getMessage()); + } + + +// return $models; + } + + /** + * 从懂车帝URL中提取series ID + * @param string $url 完整的URL + * @return string|null series ID或null + */ + function extractSeriesId($url) { + // 解析URL获取路径部分 + $path = parse_url($url, PHP_URL_PATH); + + if (!$path) { + return null; + } + + // 按斜杠分割路径 + $pathParts = explode('/', trim($path, '/')); + + // 查找 'series' 的位置,下一个元素就是series ID + $seriesIndex = array_search('series', $pathParts); + + if ($seriesIndex !== false && isset($pathParts[$seriesIndex + 1])) { + return $pathParts[$seriesIndex + 1]; + } + + return null; + } + + /** + * 抓取车型数据(通过页面抓取) + * @param Request $request + * @return array + * @throws \Exception + */ + /*public function crawlModel(Request $request) + { + $series_id = $request->get('brand_series_id'); + + // 验证品牌是否存在 + $series = BrandSeries::where('id', $series_id)->first(); + if (!$series) { + throw new \Exception("系列ID {$series_id} 不存在"); + } + + // 获取品牌信息 + $series = BrandSeries::where('id', $series_id)->first(); + if (!$series) { + throw new \Exception("系列ID {$series_id} 不存在"); + } + + // 获取品牌信息 + $brand = Brand::where('id', $series->brand_id)->first(); + + // 执行抓取 + $crawlService = new CrawlService(); + $models = $crawlService->seriesModel($brand->brand_name . ' ' . $series->series_name); + + foreach ($models as $key => $model) { + $models[$key]['year'] = $this->extractYearFromCarModel($model['title']); + } + + return $models; + }*/ + + /** + * @param Request $request + * @return array|string + * @throws \Exception + */ + public function crawlModelCreate(Request $request) + { + $series_id = $request->input('series_id'); +// $brand_id = $request->input('brand_id'); + $models = $request->input('models'); + + // 验证系列是否存在 + $series = BrandSeries::where('id', $series_id)->first(); + if (!$series) { + throw new \Exception("系列ID {$series_id} 不存在"); + } + // 获取品牌信息 + $brand = Brand::where('id', $series->brand_id)->first(); + + // 保存车型数据 + $createdCount = 0; + $repeat = 0; + $fail = 0; + $msg = ''; + + foreach ($models as $item) { + $modelData = [ + 'brand_code' => $brand->brand_code, + 'brand_name' => $brand->brand_name, + 'series_code' => $series->series_code, + 'series_name' => $series->series_name, + 'model_name' => $item['title'], + 'model_year' => $item['year'], + 'msrp' => $item['msrp'], + ]; + $modelData['model_code'] = ModelNaming::buildModelCode($modelData); + $modelData['sku_code'] = SkuCodeBuilder::build($modelData); + // 指纹 + $modelData['fingerprint'] = Fingerprint::makeFromArray($modelData); + + try { + $model = VehVehicleSku::where('brand_name', $brand->brand_name)->where('series_name', $series->series_name)->where('model_name', $item['title'])->first(); + if (!empty($model)) { + $repeat++; + continue; + } + CrudUtil::create(VehVehicleSku::class, $modelData); + $createdCount++; + } catch (\Exception $e) { + // 记录错误但继续处理其他车型 + $msg .= "创建车型失败: " . $e->getMessage() . "\n"; + Log::error("创建车型失败:" . $msg, ['series_id' => $series_id, 'model' => $item]); + $fail++; + } + } + + Log::info('series-crawl-' . $series_id . ' ' . $msg); + $msg = '创建车型成功' . $createdCount . '条'; + $msg .= (!empty($repeat)) ? ',重复' . $repeat . '条' : ''; + $msg .= (!empty($fail)) ? ',失败' . $fail . '条' : ''; + return $msg; + } + + + /** + * 从车型名中提取年份 + * @param string $carModel 车型名称 + * @return string|null 提取到的年份或null + */ + function extractYearFromCarModel($carModel) + { + // 匹配4位数字 + "款" 的模式 + if (preg_match('/(\d{4})款/', $carModel, $matches)) { + return $matches[1]; + } + + // 匹配4位数字 + 空格或结尾的模式 + if (preg_match('/^(\d{4})(?=\s|$)/', $carModel, $matches)) { + return $matches[1]; + } + + return null; + } } \ No newline at end of file diff --git a/app/admin/service/BrandService.php b/app/admin/service/BrandService.php index 61e1e65..c7775b8 100644 --- a/app/admin/service/BrandService.php +++ b/app/admin/service/BrandService.php @@ -3,9 +3,14 @@ namespace app\admin\service; use app\admin\validate\BrandValidate; +use app\common\model\BrandSeries; +use app\common\service\CrawlService; use app\common\traits\crud\CrudServiceTrait; use app\common\model\Brand; +use app\common\utils\CrudUtil; +use support\Log; use support\Request; +use Webman\RedisQueue\Redis; class BrandService { @@ -27,7 +32,7 @@ class BrandService */ public function getValidator() { - return new $this->validator; + return new $this->validator; } /** @@ -49,13 +54,13 @@ class BrandService // ====== ====== 筛选 ====== ====== // 状态 - if (isset($p['status']) && in_array($p['status'], [0,1])) { + if (isset($p['status']) && in_array($p['status'], [0, 1])) { $config['where']['status'] = $p['status']; } // 关键字 if (isset($p['keyword']) && $p['keyword']) { $k = '%' . $p['keyword'] . '%'; - foreach (['brand_code','brand_name'] as $col) { + foreach (['brand_code', 'brand_name'] as $col) { $config['orWhere'][] = [$col, 'like', $k]; } } @@ -77,4 +82,73 @@ class BrandService return $config; } + + public function crawlSeries(Request $request) + { + $brand_id = $request->get('brand_id'); + + // 验证品牌是否存在 + $brand = Brand::where('id', $brand_id)->first(); + if (!$brand) { + throw new \Exception("品牌ID {$brand_id} 不存在"); + } + + // 获取品牌信息 + try { + // 执行抓取 + $crawlService = new CrawlService(); + $series = $crawlService->brandSeries($brand->brand_name); + + if (empty($series)) { + throw new \Exception("未抓取到品牌 {$brand->brand_name} 的系列数据"); + } + + return ['series' => $series]; + + } catch (\Throwable $th) { + Log::error($th); + throw $th; + } + } + + public function crawlSeriesCreate(Request $request) + { + $brand_id = $request->input('brand_id'); + $series = $request->input('series'); + + // 保存系列数据 + $seriesService = new \app\admin\service\BrandSeriesService(); + $createdCount = 0; + $repeat = 0; + $fail = 0; + $msg = ''; + + foreach ($series as $item) { + $seriesData = [ + 'brand_id' => $brand_id, + 'series_code' => $seriesService->generateUniqueModificationCode($brand_id), + 'series_name' => $item, + ]; + + try { + $brandseries = BrandSeries::where('brand_id', $brand_id)->where('series_name', $item)->first(); + if (!empty($brandseries)) { + $repeat++; + continue; + } + CrudUtil::create(BrandSeries::class, $seriesData); + $createdCount++; + + } catch (\Exception $e) { + // 记录错误但继续处理其他系列 + $msg .= "创建系列失败: " . $e->getMessage() . "\n"; + $fail++; + } + } + Log::info('brand-crawl-' . $brand_id . ' ' . $msg); + $msg = '创建系列成功' . $createdCount . '条'; + $msg .= (!empty($repeat))?',重复' . $repeat . '条':''; + $msg .= (!empty($fail))?',失败' . $fail . '条':''; + return $msg; + } } \ 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..00faa00 --- /dev/null +++ b/app/common/controller/BrandSeriesController.php @@ -0,0 +1,85 @@ +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()); + } + } +} \ No newline at end of file diff --git a/app/common/controller/PresetController.php b/app/common/controller/PresetController.php index 1abe3de..b1c428e 100644 --- a/app/common/controller/PresetController.php +++ b/app/common/controller/PresetController.php @@ -19,6 +19,7 @@ use app\common\service\SystemsService; use app\common\service\UserService; use support\Cache; use app\common\service\PermService; +use app\common\service\UserGroupService; class PresetController extends BaseController { @@ -207,4 +208,26 @@ class PresetController extends BaseController 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/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/VehVehicleSkuController.php b/app/common/controller/VehVehicleSkuController.php index 3272f28..a3661da 100644 --- a/app/common/controller/VehVehicleSkuController.php +++ b/app/common/controller/VehVehicleSkuController.php @@ -2,6 +2,8 @@ namespace app\common\controller; use app\common\controller\BaseController; +use app\common\model\BrandSeries; +use app\common\service\VehModificationService; use app\common\traits\crud\CrudControllerTrait; use app\common\service\VehVehicleSkuService; use support\exception\BusinessException; @@ -89,6 +91,22 @@ class VehVehicleSkuController extends BaseController } } + 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) { diff --git a/app/common/logic/UserGroupLogic.php b/app/common/logic/UserGroupLogic.php new file mode 100644 index 0000000..7ec6860 --- /dev/null +++ b/app/common/logic/UserGroupLogic.php @@ -0,0 +1,79 @@ + 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/VehVehicleSkuLogic.php b/app/common/logic/VehVehicleSkuLogic.php index 505efde..43f7172 100644 --- a/app/common/logic/VehVehicleSkuLogic.php +++ b/app/common/logic/VehVehicleSkuLogic.php @@ -52,4 +52,9 @@ class VehVehicleSkuLogic $plain = implode('|', $parts); return hash('sha256', $plain); } + + public function getList($data) + { + return VehVehicleSku::where($data)->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/model/ApprovalInstances.php b/app/common/model/ApprovalInstances.php index a7a2454..e683eb4 100644 --- a/app/common/model/ApprovalInstances.php +++ b/app/common/model/ApprovalInstances.php @@ -75,6 +75,7 @@ class ApprovalInstances extends Model // ====== ====== ====== 映射业务表 ====== ====== ====== 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, ]; @@ -85,7 +86,7 @@ class ApprovalInstances extends Model public function bizInfo() { - return $this->morphTo('bizInfo', 'biz_type', 'biz_id'); + return $this->morphTo('bizInfo', 'biz_type', 'biz_id')->withTrashed(); } // ====== ====== ====== 映射表业务 ====== ====== ====== diff --git a/app/common/model/StockOrders.php b/app/common/model/StockOrders.php index 0b97fe4..2ea6ded 100644 --- a/app/common/model/StockOrders.php +++ b/app/common/model/StockOrders.php @@ -4,9 +4,13 @@ namespace app\common\model; use support\Model; use app\common\traits\HasMoneyJournal; +use Illuminate\Database\Eloquent\SoftDeletes; class StockOrders extends Model { + + use SoftDeletes; + /** * The connection name for the model. * 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/service/ApprovalRecordsService.php b/app/common/service/ApprovalRecordsService.php index b1bc4ba..3b37365 100644 --- a/app/common/service/ApprovalRecordsService.php +++ b/app/common/service/ApprovalRecordsService.php @@ -136,6 +136,7 @@ class ApprovalRecordsService $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() }; @@ -154,6 +155,14 @@ class ApprovalRecordsService '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', diff --git a/app/common/service/CrawlService.php b/app/common/service/CrawlService.php new file mode 100644 index 0000000..a2c69d4 --- /dev/null +++ b/app/common/service/CrawlService.php @@ -0,0 +1,76 @@ +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; + } +} \ 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 index cebd4b9..3d49a6a 100644 --- a/app/common/service/MessageNotificationsService.php +++ b/app/common/service/MessageNotificationsService.php @@ -154,7 +154,7 @@ class MessageNotificationsService ]; // 执行推送 - (new \app\common\utils\WsEvents())->pushWsNotice($data, $userId); + (new \app\common\utils\WsEvents())->pushWebsocket('notify.receive', $userId, $data); } return true; } diff --git a/app/common/service/StockOrdersService.php b/app/common/service/StockOrdersService.php index ee34158..6eab6dd 100644 --- a/app/common/service/StockOrdersService.php +++ b/app/common/service/StockOrdersService.php @@ -393,6 +393,44 @@ class StockOrdersService 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(); + $appro->startNullify('stock_orders_nullify', 'stock_orders', $item->id, [], $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); + } + /** * 创建 * 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/VehVehicleSkuService.php b/app/common/service/VehVehicleSkuService.php index e494122..a81adce 100644 --- a/app/common/service/VehVehicleSkuService.php +++ b/app/common/service/VehVehicleSkuService.php @@ -5,6 +5,9 @@ namespace app\common\service; use app\common\logic\Fingerprint; use app\common\logic\ModelNaming; use app\common\logic\SkuCodeBuilder; +use app\common\logic\VehVehicleSkuLogic; +use app\common\model\Brand; +use app\common\model\BrandSeries; use app\common\validate\VehVehicleSkuValidate; use app\common\traits\crud\CrudServiceTrait; use app\common\model\VehVehicleSku; @@ -25,6 +28,13 @@ class VehVehicleSkuService */ protected $validator = VehVehicleSkuValidate::class; + protected $logic; + + public function __construct() + { + $this->logic = (new VehVehicleSkuLogic()); + } + /** * 获取验证器 */ @@ -111,6 +121,15 @@ class VehVehicleSkuService $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']; @@ -138,6 +157,33 @@ class VehVehicleSkuService 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; @@ -177,4 +223,12 @@ class VehVehicleSkuService return $res; } + + /** + * 获取车型list + */ + public function getList($data) + { + return $this->logic->getList($data); + } } \ No newline at end of file diff --git a/app/common/utils/WsEvents.php b/app/common/utils/WsEvents.php index 2a65f3e..64ecd4f 100644 --- a/app/common/utils/WsEvents.php +++ b/app/common/utils/WsEvents.php @@ -53,6 +53,16 @@ class WsEvents } } + protected function eventToMethod(string $event): string + { + $parts = explode('.', $event); + $method = array_shift($parts); + foreach ($parts as $part) { + $method .= ucfirst($part); + } + return $method; + } + // ================= 系统事件 ================= /** @@ -74,16 +84,16 @@ class WsEvents } // 连接是否满足条件 - // if ($payload['server']['HTTP_ORIGIN'] != 'https://wstool.js.org') { - // 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'])) { @@ -99,9 +109,14 @@ class WsEvents // 解析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', @@ -119,6 +134,13 @@ class WsEvents } } + public function logout(string $client_id) + { + if ($_SESSION !== null) { + var_dump($_SESSION); + } + } + /** * 系统心跳 * @@ -134,6 +156,18 @@ class WsEvents } + public function systemConnect(?string $traceId) + { + $num = (new \app\common\utils\approval\service\ApprovalFlowService())->getInitTodoCount($_SESSION['uid']); + $this->todoCounts($_SESSION['uid'], $num); + + Gateway::sendToClient($this->clientId, WsResponse::success( + 'system.connect.receive', + ['time' => time()], + $traceId + )); + } + // ================= 消息通知事件 ================= /** * 管理端主动推送通知 @@ -223,22 +257,55 @@ class WsEvents )); } - /** - * 推送消息通知 + + /* + init.pull + approval.todo.counts 审批待办数量 + */ - public function pushWsNotice(array $wsData, int $userId) + + // ================= 主动推送 ================= + + public function pushWebsocket(string $event, int|string $uid, array $data) { - Gateway::sendToUid($userId, WsResponse::success('notify.receive', $wsData)); + 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'), + }; } - // ================= 辅助方法 ================= - protected function eventToMethod(string $event): string + + + + + + + + + + + // ================= 临时业务处理 ================= + + public function todoCounts(int $userId, int $num = 0) { - $parts = explode('.', $event); - $method = array_shift($parts); - foreach ($parts as $part) { - $method .= ucfirst($part); + $ly = [ + 1 => 186, + 3 => 226, + ]; + + if ($num === 0) { + $num = (new \app\common\utils\approval\service\ApprovalFlowService())->getInitTodoCount($userId); } - return $method; + + $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/approval/service/ApprovalFlowService.php b/app/common/utils/approval/service/ApprovalFlowService.php index 6241057..6eae8d8 100644 --- a/app/common/utils/approval/service/ApprovalFlowService.php +++ b/app/common/utils/approval/service/ApprovalFlowService.php @@ -3,6 +3,7 @@ namespace app\common\utils\approval\service; use support\exception\BusinessException; use support\Db; +use support\Log; class ApprovalFlowService { @@ -69,6 +70,8 @@ class ApprovalFlowService $this->notifyCCs($firstStep, $instance, 'pending'); + $this->pushTodoChanged($approverIds); + return ['instance_id' => $instanceId]; }); } @@ -151,12 +154,18 @@ class ApprovalFlowService $outcome = $this->getStepOutcome($instanceId, $round, $idx, $type); // 'pending' | 'approve' | 'reject' if ($outcome === 'pending') { + $this->pushTodoChanged([$approverId]); return ['status' => 'step_pending', 'msg' => '审批待定']; } + $otherPendingUids = $this->getPendingApproverIds($instanceId, $round, $idx); + // 3) 有结论:关闭本步其余 pending $this->closeOtherPending($instanceId, $round, $idx, [$approverId]); + // 这些人 pending→skipped,待办数减少 + $this->pushTodoChanged($otherPendingUids); + // 4) 拒绝 => 结束 if ($outcome === 'reject') { Db::table('approval_instances')->where('id', $instanceId)->update([ @@ -164,7 +173,9 @@ class ApprovalFlowService 'finished_at' => $now, 'updated_at' => $now, ]); + $this->onFlowFinalized($instance, 'rejected'); $this->sendNotification([$instance->initiator_id], $instance, 'reject', false); + $this->pushTodoChanged([$approverId]); return ['status' => 'completed', 'msg' => '拒绝']; } @@ -173,13 +184,18 @@ class ApprovalFlowService $nextIndex = $this->getNextStepIndex($steps, $formData, $idx); if ($nextIndex === null) { + $finalStatus = $this->normalizeFinalStatus((string)$instance->flow_type, 'approved'); + $notifyAction = ($finalStatus === 'voided') ? 'voided' : 'approve'; + Db::table('approval_instances')->where('id', $instanceId)->update([ - 'status' => 'approved', + 'status' => $finalStatus, 'finished_at' => $now, 'updated_at' => $now, ]); - $this->sendNotification([$instance->initiator_id], $instance, 'approve', false); - return ['status' => 'completed', 'msg' => '同意']; + $this->onFlowFinalized($instance, $finalStatus); + $this->sendNotification([$instance->initiator_id], $instance, $notifyAction, false); + $this->pushTodoChanged([$approverId]); + return ['status' => 'completed', 'msg' => ($finalStatus === 'voided' ? '已作废' : '同意')]; } // 乐观推进 current_step @@ -200,10 +216,58 @@ class ApprovalFlowService } $this->sendNotification($nextApprovers, $instance, 'pending', false); $this->notifyCCs($nextStep, $instance, 'pending'); + $this->pushTodoChanged(array_merge([$approverId], $nextApprovers)); return ['status' => 'next_step', 'msg' => '进行下一步:'.$nextStep['name'] ?? ('Step '.$nextIndex)]; }); } + // 流程完结后的业务回调(approved|rejected|revoked|timeout?) + private function onFlowFinalized(object $instance, string $final): void + { + try { + switch ($instance->flow_type) { + case 'stock_orders': { + $order = \app\common\model\StockOrders::find($instance->biz_id); + if (!$order) { + throw new BusinessException('订单不存在'); + } + if ($final === 'approved') { + $order->approval_status = 2; + $order->save(); + } elseif ($final === 'rejected') { + $order->approval_status = 3; + $order->save(); + } elseif ($final === 'revoked') { + $order->approval_status = 0; + $order->save(); + } + break; + } + case 'stock_orders_nullify': + if ($final === 'approved') { + $final = 'voided'; + Db::table('approval_instances')->where('id', $instance->id)->update([ + 'status' => 'voided', + ]); + } + if (in_array($final, ['voided','approved'], true)) { + $order = \app\common\model\StockOrders::find($instance->biz_id); + if ($order && $order->approval_status !== 4) { + $order->approval_status = 4; + $order->save(); + } + } + break; + default: + // 非作废流程,无需处理 + break; + } + } catch (\Throwable $e) { + Log::error('onFlowFinalized error: '.$e->getMessage()); + } + } + + /** 撤回(仅发起人在无已处理记录前可撤回) */ public function revoke(int $instanceId, int $initiatorId, string $reason = '') { @@ -226,12 +290,20 @@ class ApprovalFlowService 'updated_at' => $now, ]); + $this->onFlowFinalized($instance, 'revoked'); + + $pendingUids = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('status', 'pending') + ->pluck('approver_id')->toArray(); + Db::table('approval_records') ->where('instance_id', $instanceId) ->where('status', 'pending') ->update(['status' => 'skipped', 'approved_at' => $now, 'updated_at' => $now]); $this->sendNotification([$initiatorId], $instance, 'revoked', false); + $this->pushTodoChanged($pendingUids); return ['status' => 'completed', 'result' => 'revoked']; }); } @@ -310,8 +382,9 @@ class ApprovalFlowService $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); if (!$flow) return; - $final = ($flow->timeout_action === 'auto_approve') ? 'approved' : 'rejected'; - $finalAction = ($flow->timeout_action === 'auto_approve') ? 'approve' : 'reject'; + $final = ($flow->timeout_action === 'auto_approve') ? 'approved' : 'rejected'; + $final = $this->normalizeFinalStatus((string)$instance->flow_type, $final); + $finalAction = ($final === 'voided') ? 'voided' : (($final === 'approved') ? 'approve' : 'reject'); Db::table('approval_instances')->where('id', $instance->id)->update([ 'status' => $final, @@ -319,6 +392,15 @@ class ApprovalFlowService 'updated_at' => $now, ]); + $this->onFlowFinalized((object)$instance, $final); + + $pendingUids = Db::table('approval_records') + ->where('instance_id', $instance->id) + ->where('round', (int)$instance->round) + ->where('step_index', $instance->current_step) + ->where('status', 'pending') + ->pluck('approver_id')->toArray(); + Db::table('approval_records') ->where('instance_id', $instance->id) ->where('round', (int)$instance->round) @@ -327,6 +409,8 @@ class ApprovalFlowService ->update(['status' => 'timeout', 'approved_at' => $now, 'updated_at' => $now]); $this->notifyInitiator($instance, $finalAction); + + $this->pushTodoChanged($pendingUids); }); } } @@ -365,6 +449,32 @@ class ApprovalFlowService $head = $this->getDeptHeadId((int)$val); if ($head) $ids = [(int)$head]; break; + + case 'group_members': { + // $gid = (int)($cfg['group_id'] ?? 0); + $gid = (int)($cfg['value'] ?? 0); + if ($gid <= 0) throw new BusinessException('group_members 需要 group_id'); + $ids = $this->getGroupMemberIds($gid); // A 方案 + break; + } + + case 'scenario_leader_of_initiator': { + $formData = json_decode($instance->form_data ?? '{}', true) ?: []; + // $scenario = $cfg['scenario'] ?? ($formData[$cfg['scenario_field']] ?? null); + $scenario = $val ?? ($formData[$cfg['scenario_field']] ?? null); + if (!$scenario) throw new BusinessException('缺少业务场景(scenario 或 scenario_field)'); + + $mustBelong = array_key_exists('must_belong', $cfg) ? (bool)$cfg['must_belong'] : true; + $preferPrimary = array_key_exists('prefer_primary', $cfg) ? (bool)$cfg['prefer_primary'] : true; + + $ids = $this->getLeadersByScenarioForInitiator( + (int)$instance->initiator_id, + (string)$scenario, + $mustBelong, + $preferPrimary + ); // B 方案(按场景) + break; + } default: throw new BusinessException("不支持的审批人类型: {$typ}"); } @@ -411,6 +521,28 @@ class ApprovalFlowService $head = $this->getDeptHeadId((int)$val); if ($head) $uids[] = (int)$head; break; + case 'group_members': { + $gid = (int)($val ?? 0); + if ($gid <= 0) throw new BusinessException('group_members 需要 group_id'); + $uids = $this->getGroupMemberIds($gid); // A 方案 + break; + } + case 'scenario_leader_of_initiator': { + $formData = json_decode($instance->form_data ?? '{}', true) ?: []; + $scenario = $val ?? ($formData[$cc['scenario_field']] ?? null); + if (!$scenario) throw new BusinessException('缺少业务场景(scenario 或 scenario_field)'); + + $mustBelong = array_key_exists('must_belong', $cc) ? (bool)$cc['must_belong'] : true; + $preferPrimary = array_key_exists('prefer_primary', $cc) ? (bool)$cc['prefer_primary'] : true; + + $uids = $this->getLeadersByScenarioForInitiator( + (int)$instance->initiator_id, + (string)$scenario, + $mustBelong, + $preferPrimary + ); // B 方案(按场景) + break; + } default: break; } } @@ -474,35 +606,37 @@ class ApprovalFlowService /** * 聚合本步骤结论(按类型) - * 返回:'pending' | 'approve' | 'reject' - * - * any:有 reject => reject;否则有 approve => approve;否则 pending - * all:有 reject => reject;有 pending => pending;否则(全 approved 或 approved+skipped/timeout)=> approve */ private function getStepOutcome(int $instanceId, int $round, int $stepIndex, string $type): string { $records = Db::table('approval_records') ->where('instance_id', $instanceId) - ->where('round', $round) // ★ + ->where('round', $round) // ->where('step_index', $stepIndex) ->get(); - $pending = 0; $approved = 0; $rejected = 0; + $pending = $approved = $rejected = $timeout = $skipped = 0; foreach ($records as $r) { - if ($r->status === 'pending') $pending++; - elseif ($r->status === 'approved') $approved++; - elseif ($r->status === 'rejected') $rejected++; + switch ($r->status) { + case 'pending': $pending++; break; + case 'approved': $approved++; break; + case 'rejected': $rejected++; break; + case 'timeout': $timeout++; break; + case 'skipped': $skipped++; break; + } } $type = strtolower($type); if ($type === 'any') { if ($rejected > 0) return 'reject'; if ($approved > 0) return 'approve'; + // 全为 skipped/timeout 且无 pending/rejected ⇒ 通过 + if ($pending === 0 && $rejected === 0 && $approved === 0) return 'approve'; return 'pending'; } if ($rejected > 0) return 'reject'; if ($pending > 0) return 'pending'; - return $approved > 0 ? 'approve' : 'pending'; + return 'approve'; } /** 关闭本步其余待办为 skipped */ @@ -511,7 +645,7 @@ class ApprovalFlowService $now = date('Y-m-d H:i:s'); $q = Db::table('approval_records') ->where('instance_id', $instanceId) - ->where('round', $round) // ★ + ->where('round', $round) // ->where('step_index', $stepIndex) ->where('status', 'pending'); @@ -528,7 +662,7 @@ class ApprovalFlowService $exists = Db::table('approval_records') ->where('instance_id', (int)$instance->id) - ->where('round', $round) // ★ 限当前轮 + ->where('round', $round) // 限当前轮 ->where('step_index', $stepIndex) ->where('approver_id', $approverId) ->where('status', 'pending') @@ -540,7 +674,7 @@ class ApprovalFlowService 'instance_id' => (int)$instance->id, 'approver_id' => $approverId, 'step_index' => $stepIndex, - 'round' => $round, // ★ + 'round' => $round, // 'action' => null, 'status' => 'pending', 'comment' => null, @@ -558,6 +692,7 @@ class ApprovalFlowService 'approve' => '已通过', 'reject' => '已拒绝', 'revoked' => '已撤回', + 'voided' => '已作废', ]; $prefix = $isCC ? '【抄送】' : '【审批】'; $text = $map[$action] ?? $action; @@ -565,6 +700,10 @@ class ApprovalFlowService // 业务标题/内容 $approvalType = array_flip(\app\common\service\SysDictService::pluckKeyValueByCode('approval_type')); + $flowTxt = $approvalType[$instance->flow_type] ?? (string)$instance->flow_type; + $title = $prefix.'审批通知'; + $content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务标识 {$instance->biz_id}"; + switch ($instance->flow_type) { case 'stock_orders': // 备货单 @@ -577,6 +716,17 @@ class ApprovalFlowService break; case 'stock_orders_nullify': // 备货单作废 + $stockOrder = \app\common\model\StockOrders::find($instance->biz_id); + if ($stockOrder) { + $title = $prefix."备货单作废:{$stockOrder->order_no}"; + $flowTxt = $approvalType[$instance->flow_type] ?? '备货单作废'; + $content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务单号 {$stockOrder->order_no}"; + // 把作废原因附带展示更清晰(也可依赖 $extra['note']) + $fd = json_decode($instance->form_data ?? '{}', true) ?: []; + if (!empty($fd['_nullify_reason'])) { + $content .= ';原因:'.(function_exists('mb_strimwidth') ? mb_strimwidth($fd['_nullify_reason'], 0, 120, '...', 'UTF-8') : $fd['_nullify_reason']); + } + } break; case 'purchase_orders': // 采购单 @@ -785,178 +935,6 @@ class ApprovalFlowService ]; } - /** - * 极简时间轴(含每步时间 & 申请人信息) - * - * @param int $instanceId - * @param string|null $userNameColumn 申请人姓名列(如 'name' / 'nickname')。不需要就传 null - * @param string $initiatorPlacement 'prepend' | 'attach' - * - 'prepend':在最前插入一条“发起申请”块(推荐) - * - 'attach' :把申请人信息并到第一步的返回项里(不新增块) - * - * 返回:按显示顺序的数组,每项: - * { - * index:int // 步骤索引;若为“发起申请”块则为 -1 - * name:string // 步骤名或“发起申请” - * type:string // any|all 或 '-'(发起块) - * status:string // approved|rejected|processing|pending|skipped|timeout|submitted(发起块) - * started_at:?string - * finished_at:?string - * duration_seconds:?int - * // 仅发起块或 attach 时的第一步会包含: - * initiator_id?:int - * initiator_name?:?string - * } - */ - public function getSimpleTimeline2(int $instanceId, ?string $userNameColumn = null, string $initiatorPlacement = 'prepend'): array - { - // 基础数据 - $instance = Db::table('approval_instances')->where('id', $instanceId)->first(); - if (!$instance) throw new BusinessException('审批实例未找到'); - - $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); - if (!$flow) throw new BusinessException('审批流程定义未找到'); - - $steps = json_decode($flow->steps, true) ?: []; - $formData = json_decode($instance->form_data ?? '{}', true) ?: []; - $curStep = (int)$instance->current_step; - $round = (int)$instance->round; - - // 申请人 - $initiatorId = (int)$instance->initiator_id; - $initiatorName = null; - if ($userNameColumn) { - $initiatorName = Db::table('user')->where('id', $initiatorId)->value($userNameColumn); - } - - // 审批记录 - $records = Db::table('approval_records as ar') - ->where('ar.instance_id', $instanceId) - ->where('ar.round', $round) - ->orderBy('ar.step_index') - ->orderByRaw('COALESCE(ar.approved_at, ar.created_at) asc') - ->select('ar.*') - ->get() - ->toArray(); - - $byStep = []; - foreach ($records as $r) { - $idx = (int)$r->step_index; - $byStep[$idx][] = $r; - } - - $timeline = []; - - // 可选:先插入“发起申请”块(index = -1) - if ($initiatorPlacement === 'prepend') { - $appliedAt = $instance->created_at ?: null; - $timeline[] = [ - 'index' => -1, - 'name' => '发起申请', - 'type' => '-', - 'status' => 'submitted', - 'started_at' => $appliedAt, - 'finished_at' => $appliedAt, - 'duration_seconds' => 0, - 'initiator_id' => $initiatorId, - 'initiator_name' => $initiatorName, - ]; - } - - // 每一步 - foreach ($steps as $i => $meta) { - $name = $meta['name'] ?? ('Step '.$i); - $typeRaw = strtolower($meta['type'] ?? 'all'); - $type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all'; - $condStr = $meta['condition'] ?? null; - $condOk = $condStr ? $this->evaluateCondition($condStr, $formData) : true; - - $recs = $byStep[$i] ?? []; - $hasRecs = !empty($recs); - - $status = 'pending'; - $startedAt = null; - $finishedAt = null; - $durationSec = null; - - if ($hasRecs) { - // 统计时间 - $minCreated = null; - $minApproveAt = null; $maxApproveAt = null; - $minRejectAt = null; - $maxTimeoutAt = null; - $cnt = ['pending'=>0,'approved'=>0,'rejected'=>0,'timeout'=>0]; - - foreach ($recs as $rr) { - if ($rr->created_at && (!$minCreated || $rr->created_at < $minCreated)) $minCreated = $rr->created_at; - if ($rr->approved_at) { - if ($rr->status === 'approved') { - if (!$minApproveAt || $rr->approved_at < $minApproveAt) $minApproveAt = $rr->approved_at; - if (!$maxApproveAt || $rr->approved_at > $maxApproveAt) $maxApproveAt = $rr->approved_at; - } elseif ($rr->status === 'rejected') { - if (!$minRejectAt || $rr->approved_at < $minRejectAt) $minRejectAt = $rr->approved_at; - } elseif ($rr->status === 'timeout') { - if (!$maxTimeoutAt || $rr->approved_at > $maxTimeoutAt) $maxTimeoutAt = $rr->approved_at; - } - } - if (isset($cnt[$rr->status])) $cnt[$rr->status]++; - } - - $startedAt = $minCreated; - - // 总结果 - $outcome = $this->getStepOutcome($instanceId, $round, $i, $type); // 'pending'|'approve'|'reject' - if ($outcome === 'reject') { - $status = 'rejected'; - $finishedAt = $minRejectAt; - } elseif ($outcome === 'approve') { - $status = 'approved'; - $finishedAt = ($type === 'any') ? $minApproveAt : $maxApproveAt; - } else { - if ($cnt['timeout'] > 0 && $cnt['approved'] === 0 && $cnt['rejected'] === 0 && $cnt['pending'] === 0) { - $status = 'timeout'; - $finishedAt = $maxTimeoutAt; - } else { - $status = 'processing'; - } - } - - if ($startedAt && $finishedAt) { - $durationSec = max(0, strtotime($finishedAt) - strtotime($startedAt)); - } - } else { - // 无记录:根据位置与条件展示 - if ($i < $curStep) { - $status = $condOk ? 'approved' : 'skipped'; - } elseif ($i === $curStep && $instance->status === 'processing') { - $status = 'processing'; - } else { - $status = $condOk ? 'pending' : 'skipped'; - } - } - - $row = [ - 'index' => (int)$i, - 'name' => $name, - 'type' => $type, - 'status' => $status, - 'started_at' => $startedAt, - 'finished_at' => $finishedAt, - 'duration_seconds' => $durationSec, - ]; - - // 如果需要把申请人信息“并到第一步”而不是独立一块 - if ($initiatorPlacement === 'attach' && empty($timeline)) { - $row['initiator_id'] = $initiatorId; - $row['initiator_name'] = $initiatorName; - } - - $timeline[] = $row; - } - - return $timeline; - } - public function getSimpleTimeline(int $instanceId, ?string $userNameColumn = null, string $initiatorPlacement = 'prepend'): array { @@ -1042,7 +1020,7 @@ class ApprovalFlowService $finishedAt = null; $durationSec = null; - // ★ 每一步的“审批意见”(只展示有内容的 comment) + // 每一步的“审批意见”(只展示有内容的 comment) $opinions = []; // [{approver_id, approver_name?, action, status, comment, time}...] if ($hasRecs) { @@ -1129,7 +1107,7 @@ class ApprovalFlowService 'started_at' => $startedAt, 'finished_at' => $finishedAt, 'duration_seconds' => $durationSec, - 'opinions' => $opinions, // ★ 这就是每个审批意见 + 'opinions' => $opinions, // 这就是每个审批意见 ]; // 把申请人信息并到第一步(可选) @@ -1309,13 +1287,18 @@ class ApprovalFlowService $deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600); } + $oldPendingUids = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('status', 'pending') + ->pluck('approver_id')->toArray(); + // 清理可能遗留的 pending Db::table('approval_records') ->where('instance_id', $instanceId) ->where('status', 'pending') ->update(['status'=>'skipped','approved_at'=>$now,'updated_at'=>$now]); - // ★ 轮次 +1 + // 轮次 +1 $newRound = ((int)$instance->round) + 1; // 重新开启实例 @@ -1350,6 +1333,7 @@ class ApprovalFlowService $extra = $appealText !== '' ? ['appeal' => $appealText] : []; $this->sendNotification($approvers, $instanceLite, 'pending', false, $extra); $this->notifyCCs($firstStep, $instanceLite, 'pending'); + $this->pushTodoChanged(array_merge($oldPendingUids, $approvers)); return [ 'status' => 'resubmitted', @@ -1520,95 +1504,6 @@ class ApprovalFlowService // ====== ====== ====== ====== ====== ====== 转交操作 ====== ====== ====== ====== ====== ====== - /** - * 转交当前待办给他人 - * 仅允许【当前这条待办的审批人】把自己名下且为 pending 的记录转交给 toUserId。 - * - 若目标人在本步已有 pending:合并(把本记录标记为 skipped,保留目标人的那条) - * - 否则:原地把该记录的 approver_id 改为 toUserId - * - 写入转交日志 - * - 通知被转交人 - */ - public function transferApproval2(int $instanceId, int $stepIndex, int $fromUserId, int $toUserId, string $reason = ''): array - { - if ($fromUserId === $toUserId) { - return ['status' => 'noop', 'msg' => '目标与来源相同,无需转交']; - } - - return Db::transaction(function () use ($instanceId, $stepIndex, $fromUserId, $toUserId, $reason) { - // 锁实例,避免并发推进 - $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); - if (!$instance) throw new BusinessException('审批实例未找到'); - if ($instance->status !== 'processing') throw new BusinessException('流程非进行中,无法转交'); - - // 锁定来源人的 pending 记录(必须是“自己名下”的记录) - $record = Db::table('approval_records') - ->where('instance_id', $instanceId) - ->where('round', (int)$instance->round) - ->where('step_index', $stepIndex) - ->where('approver_id', $fromUserId) - ->where('status', 'pending') - ->lockForUpdate() - ->first(); - - if (!$record) { - throw new BusinessException('未找到可转交的待办或已处理'); - } - - $now = date('Y-m-d H:i:s'); - - // 若目标人在本步已有 pending,合并:本记录设为 skipped,保留目标那条 - $target = Db::table('approval_records') - ->where('instance_id', $instanceId) - ->where('round', (int)$instance->round) - ->where('step_index', $stepIndex) - ->where('approver_id', $toUserId) - ->where('status', 'pending') - ->lockForUpdate() - ->first(); - - if ($target) { - Db::table('approval_records')->where('id', $record->id)->update([ - 'status' => 'skipped', - 'approved_at' => $now, - 'updated_at' => $now, - 'comment' => trim(($record->comment ?? '')."\n[转交给 {$toUserId},目标已有待办,已合并]"), - ]); - $effectiveRecordId = (int)$target->id; - } else { - // 原地替换 approver_id - Db::table('approval_records')->where('id', $record->id)->update([ - 'approver_id' => $toUserId, - 'updated_at' => $now, - 'comment' => trim(($record->comment ?? '')."\n[自 {$fromUserId} 转交]"), - ]); - $effectiveRecordId = (int)$record->id; - } - - // 写转交日志 - Db::table('approval_transfers')->insert([ - 'instance_id' => (int)$instanceId, - 'step_index' => (int)$stepIndex, - 'record_id' => $effectiveRecordId, - 'from_user_id' => (int)$fromUserId, - 'to_user_id' => (int)$toUserId, - 'reason' => $reason, - 'created_at' => $now, - ]); - - // 通知被转交人(沿用你的 sendNotification) - $this->sendNotification([$toUserId], (object)[ - 'id' => (int)$instance->id, - 'flow_type' => (string)$instance->flow_type, - 'biz_type' => (string)$instance->biz_type, - 'biz_id' => (string)$instance->biz_id, - 'initiator_id' => (int)$instance->initiator_id, - 'form_data' => $instance->form_data, - ], 'pending', false); - - return ['status' => 'ok', 'record_id' => $effectiveRecordId]; - }); - } - /** * 查看某个实例的转交流水(按时间升序) */ @@ -1731,8 +1626,322 @@ class ApprovalFlowService 'created_at' => $now, ]); + $this->pushTodoChanged([$fromUserId, $toUserId]); return ['status' => 'ok', 'record_id' => $effectiveRecordId]; }); } + + + /** 新增:发起“作废审批” */ + public function startNullify( + string $voidFlowType, // 例如 'stock_orders_nullify' + string $bizType, // 原业务类型,如 'stock_orders' + string $bizId, // 原业务ID + array $formData, // 可包含 ['nullify_reason'=>...] + int $initiatorId + ): array { + return Db::transaction(function () use ($voidFlowType, $bizType, $bizId, $formData, $initiatorId) { + // 1) 流程定义校验 + $flow = Db::table('approval_flows')->where('type', $voidFlowType)->where('is_active', 1)->first(); + if (!$flow) throw new BusinessException("作废流程未找到或已停用: {$voidFlowType}"); + $steps = json_decode($flow->steps, true); + if (!is_array($steps) || empty($steps)) throw new BusinessException("作废流程步骤配置错误"); + + // 2) 必须存在“已通过”的最新有效实例(非作废流程) + $latestApproved = Db::table('approval_instances') + ->where('biz_type', $bizType) + ->where('biz_id', $bizId) + ->where('flow_type', '!=', $voidFlowType) + ->where('status', 'approved') + ->orderByDesc('id') + ->first(); + + if (!$latestApproved) { + throw new BusinessException('仅已通过的单据可发起作废'); + } + + // 3) 不允许并行的作废流程 + $voidProcessing = Db::table('approval_instances') + ->where('flow_type', $voidFlowType) + ->where('biz_id', $bizId) + ->where('status', 'processing') + ->exists(); + if ($voidProcessing) { + throw new BusinessException('已存在进行中的作废审批'); + } + + // A. 基于历史“作废流程已通过”的实例拦截 + $voidApproved = Db::table('approval_instances') + ->where('flow_type', $voidFlowType) + ->where('biz_id', $bizId) + ->whereIn('status', ['approved','voided']) + ->exists(); + if ($voidApproved) { + throw new BusinessException('该单据已作废,无需重复作废'); + } + + // B.(可选)直接查业务表状态拦截(若你用 approval_status=4 表示已作废) + /* + $orderVoided = \app\common\model\StockOrders::where('id', $bizId) + ->where('approval_status', 4) + ->exists(); + if ($orderVoided) { + throw new BusinessException('该单据已作废,无需重复作废'); + } + */ + + // 4) 计算首步 + // 注:作废流程可以有独立条件;若不需要条件,直接第一步 + $formData = array_merge([ + '_op' => 'nullify', + '_nullify_reason' => $formData['nullify_reason'] ?? ($formData['reason'] ?? ''), + '_nullify_of_instance_id' => (int)$latestApproved->id, + ], $formData); + + $firstIndex = $this->getNextStepIndex($steps, $formData, -1); + if ($firstIndex === null) throw new BusinessException("作废流程未命中任何审批步骤"); + $firstStep = $steps[$firstIndex]; + + $now = date('Y-m-d H:i:s'); + $deadlineAt = null; + if (($flow->timeout_hours ?? 0) > 0) { + $deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600); + } + + // 5) 新建作废实例(round 固定从 1 开始) + $instanceId = Db::table('approval_instances')->insertGetId([ + 'flow_type' => $voidFlowType, + 'biz_type' => $bizType, + 'biz_id' => $bizId, + 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'initiator_id' => $initiatorId, + 'status' => 'processing', + 'current_step' => $firstIndex, + 'round' => 1, // + 'deadline_at' => $deadlineAt, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $instance = (object)[ + 'id' => $instanceId, + 'flow_type' => $voidFlowType, + 'biz_type' => $bizType, + 'biz_id' => $bizId, + 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'initiator_id' => $initiatorId, + 'current_step' => $firstIndex, + 'status' => 'processing', + 'round' => 1, // + ]; + + // 6) 生成首步待办 + 通知(带上作废原因) + $approverIds = $this->resolveApprovers($firstStep, $instance, true); + foreach ($approverIds as $uid) { + $this->insertApprovalRecord($instance, $firstIndex, $uid); + } + $extra = []; + if (!empty($formData['_nullify_reason'])) { + $extra['note'] = '【作废原因】'.$formData['_nullify_reason']; + } + $this->sendNotification($approverIds, $instance, 'pending', false, $extra); + $this->notifyCCs($firstStep, $instance, 'pending'); + $this->pushTodoChanged($approverIds); + return ['instance_id' => $instanceId]; + }); + } + + + // A) 取组内全部成员 + private function getGroupMemberIds(int $groupId): array + { + $rows = Db::table('user_group_members') + ->where('group_id', $groupId) + ->where('status', 1) + ->pluck('user_id')->toArray(); + + return array_values(array_unique(array_map('intval', $rows))); + } + + // B) 按“场景”在发起人所在的组里找负责人(支持主归属/排序兜底) + private function getLeadersByScenarioForInitiator( + int $initiatorId, + string $scenarioCode, + bool $mustBelong = true, + bool $preferPrimary = true + ): array { + // 找出“该场景下发起人所在的所有组” + $groups = Db::table('user_group as g') + ->join('user_group_members as m', 'm.group_id', '=', 'g.id') + ->where('g.scenario_code', $scenarioCode) + ->where('g.status', 1) + ->where('m.user_id', $initiatorId) + ->where('m.status', 1) + ->orderByDesc('m.is_primary') // 主归属优先 + ->orderByDesc('g.sort') // 其次按组排序 + ->select('g.id', 'm.is_primary') + ->get()->toArray(); + + if (empty($groups)) { + if ($mustBelong) { + throw new BusinessException('发起人在该业务场景下未加入任何分组'); + } + // 不强制属于:允许只按“该场景下任一组”的负责人(不推荐) + $groups = Db::table('user_group as g') + ->where('g.scenario_code', $scenarioCode) + ->where('g.status', 1) + ->orderByDesc('g.sort') + ->select('g.id', Db::raw('0 as is_primary')) + ->get()->toArray(); + if (empty($groups)) { + throw new BusinessException('该业务场景下未配置任何分组'); + } + } + + // 选择一个“目标组” + $targetGroupId = null; + if ($preferPrimary) { + foreach ($groups as $g) { + if ((int)$g->is_primary === 1) { $targetGroupId = (int)$g->id; break; } + } + } + if (!$targetGroupId) { + $targetGroupId = (int)$groups[0]->id; // 用排序第一的组 + } + + // 取负责人(可支持多负责人) + $leaders = Db::table('user_group_members') + ->where('group_id', $targetGroupId) + ->where('is_leader', 1) + ->where('status', 1) + ->pluck('user_id')->toArray(); + + $ids = array_values(array_unique(array_map('intval', $leaders))); + if (empty($ids)) throw new BusinessException('目标分组未配置负责人'); + + return $ids; + } + + // + private function isNullifyFlow(string $flowType): bool + { + $t = strtolower(trim($flowType)); + return str_ends_with($t, '_nullify') || in_array($t, [ + // 所有“作废”类流程的类型 + 'stock_orders_nullify', + ], true); + } + + // 放到工具方法区 + private function normalizeFinalStatus(string $flowType, string $final): string + { + if ($this->isNullifyFlow($flowType) && $final === 'approved') { + return 'voided'; + } + return $final; + } + + /** WebSocket:把一组用户的“待办数量”推送给前端 */ + private function pushTodoChanged22(array $userIds): void + { + $userIds = array_values(array_unique(array_map('intval', $userIds))); + if (empty($userIds)) return; + + // 统计这些用户当前的待办数量(以 approval_records.status = 'pending' 为准) + $rows = Db::table('approval_records') + ->whereIn('approver_id', $userIds) + ->where('status', 'pending') + ->groupBy('approver_id') + ->select('approver_id', Db::raw('COUNT(*) as cnt')) + ->get(); + + // 默认都置 0,再填充真实值 + $payload = array_fill_keys($userIds, 0); + foreach ($rows as $r) { + $payload[(int)$r->approver_id] = (int)$r->cnt; + } + + try { + foreach ($payload as $uid => $cnt) { + $data = [ + 'event' => 'approval.todo.counts', + 'user_id' => $uid, + 'num' => $cnt, + ]; + \Webman\RedisQueue\Redis::send('ws-msg', $data); + } + } catch (\Throwable $e) { + Log::error('ws todo push error: '.$e->getMessage()); + } + } + + /** 取“某实例、某轮次、某步骤”里当前 pending 的审批人(用于推送受影响的用户) */ + private function getPendingApproverIds(int $instanceId, int $round, int $stepIndex): array + { + $ids = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('round', $round) + ->where('step_index', $stepIndex) + ->where('status', 'pending') + ->pluck('approver_id')->toArray(); + + return array_values(array_unique(array_map('intval', $ids))); + } + + + /** 登录/首屏初始化:获取当前用户的待办总数(仅统计当前步骤、进行中的实例) */ + public function getInitTodoCount(int $userId): int + { + $userId = (int)$userId; + if ($userId <= 0) { + throw new BusinessException('无效的用户ID'); + } + + $row = Db::table('approval_records as ar') + ->join('approval_instances as ai', 'ai.id', '=', 'ar.instance_id') + ->where('ar.approver_id', $userId) + ->where('ar.status', 'pending') + ->where('ai.status', 'processing') + // 只统计实例“当前轮次、当前步骤”的待办,避免历史残留干扰 + ->whereRaw('ar.round = ai.round') + ->whereRaw('ar.step_index = ai.current_step') + ->selectRaw('COUNT(*) as c') + ->first(); + + return (int)($row->c ?? 0); + } + + /** WebSocket:把一组用户的“待办数量”推送给前端(与 getInitTodoCount 统一口径) */ + private function pushTodoChanged(array $userIds): void + { + $userIds = array_values(array_unique(array_map('intval', $userIds))); + if (empty($userIds)) return; + + // 只统计:实例进行中 + 当前轮次 + 当前步骤 的 pending + $rows = Db::table('approval_records as ar') + ->join('approval_instances as ai', 'ai.id', '=', 'ar.instance_id') + ->whereIn('ar.approver_id', $userIds) + ->where('ar.status', 'pending') + ->where('ai.status', 'processing') + ->whereRaw('ar.round = ai.round') + ->whereRaw('ar.step_index = ai.current_step') + ->groupBy('ar.approver_id') + ->select('ar.approver_id', Db::raw('COUNT(*) as cnt')) + ->get(); + + // 默认置 0,再填充真实值 + $payload = array_fill_keys($userIds, 0); + foreach ($rows as $r) { + $payload[(int)$r->approver_id] = (int)$r->cnt; + } + + try { + foreach ($payload as $uid => $cnt) { + (new \app\common\utils\WsEvents())->todoCounts($uid, $cnt); + } + } catch (\Throwable $e) { + Log::error('ws todo push error: '.$e->getMessage()); + } + } } \ No newline at end of file diff --git a/app/common/utils/approval/service/ApprovalFlowService_20251009.php b/app/common/utils/approval/service/ApprovalFlowService_20251009.php new file mode 100644 index 0000000..88c6a63 --- /dev/null +++ b/app/common/utils/approval/service/ApprovalFlowService_20251009.php @@ -0,0 +1,1738 @@ +where('type', $flowType)->where('is_active', 1)->first(); + if (!$flow) throw new BusinessException("审批流程未找到或已停用: {$flowType}"); + + $steps = json_decode($flow->steps, true); + if (!is_array($steps) || empty($steps)) throw new BusinessException("流程步骤配置错误"); + + // 找首个有效步骤索引(考虑条件) + $firstIndex = $this->getNextStepIndex($steps, $formData, -1); + if ($firstIndex === null) throw new BusinessException("未找到可执行的审批步骤"); + $firstStep = $steps[$firstIndex]; + + $now = date('Y-m-d H:i:s'); + $deadlineAt = null; + if (($flow->timeout_hours ?? 0) > 0) { + $deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600); + } + + $instanceId = Db::table('approval_instances')->insertGetId([ + 'flow_type' => $flowType, + 'biz_type' => $bizType, + 'biz_id' => $bizId, + 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'initiator_id' => $initiatorId, + 'status' => 'processing', + 'current_step' => $firstIndex, + 'round' => 1, + 'deadline_at' => $deadlineAt, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $instance = (object)[ + 'id' => $instanceId, + 'flow_type' => $flowType, + 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'initiator_id' => $initiatorId, + 'current_step' => $firstIndex, + 'status' => 'processing', + 'flow_type' => $flowType, + 'biz_type' => $bizType, + 'biz_id' => $bizId, + 'round' => 1, + ]; + + // 解析首步审批人(支持 role_id 或 roles.name) + $approverIds = $this->resolveApprovers($firstStep, $instance, true); + foreach ($approverIds as $uid) { + $this->insertApprovalRecord($instance, $firstIndex, $uid); + } + + $extra = []; + $submitNote = $formData['submit_note'] ?? ''; + if (is_string($submitNote) && $submitNote !== '') { + $extra['note'] = $submitNote; + } + $this->sendNotification($approverIds, $instance, 'pending', false, $extra); + + $this->notifyCCs($firstStep, $instance, 'pending'); + + return ['instance_id' => $instanceId]; + }); + } + + /** 执行审批(幂等友好) */ + public function approve(int $instanceId, int $approverId, string $action, string $comment = '') + { + if (!in_array($action, ['approve','reject'], true)) { + throw new BusinessException("无效的审批操作"); + } + + return Db::transaction(function () use ($instanceId, $approverId, $action, $comment) { + $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); + if (!$instance) throw new BusinessException("审批实例未找到"); + if ($instance->status !== 'processing') { + return ['status' => 'noop', 'msg' => '实例未处理']; + } + + $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); + if (!$flow) throw new BusinessException("审批流程定义未找到"); + + $steps = json_decode($flow->steps, true); + $idx = (int)$instance->current_step; + $round = (int)$instance->round; + $step = $steps[$idx] ?? null; + if (!$step) throw new BusinessException("当前步骤配置错误"); + + // 找到该用户在本步的 pending 记录(加锁) + $pendingRecord = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('round', $round) + ->where('approver_id', $approverId) + ->where('step_index', $idx) + ->where('status', 'pending') + ->lockForUpdate() + ->first(); + + if (!$pendingRecord) { + // 已处理过? + $anyRecord = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('round', $round) + ->where('approver_id', $approverId) + ->where('step_index', $idx) + ->first(); + if ($anyRecord && $anyRecord->status !== 'pending') { + return ['status' => 'noop', 'msg' => '已处理']; + } + // 步骤已推进 + if ((int)$instance->current_step !== $idx) { + return ['status' => 'noop', 'msg' => '非当前审批']; + } + return ['status' => 'noop', 'msg' => '无待处理记录']; + } + + $now = date('Y-m-d H:i:s'); + + // 1) 更新当前人的审批记录 + Db::table('approval_records')->where('id', $pendingRecord->id)->update([ + 'action' => $action, + 'status' => ($action === 'approve') ? 'approved' : 'rejected', + 'comment' => $comment, + 'approved_at' => $now, + 'updated_at' => $now, + ]); + + // 异步审批回调 9.23 add + $approvalcallback = [ + 'biz_id' => $instance->biz_id, + 'flow_type' => $instance->flow_type, + 'instance_id' => $instance->id, + 'initiator_id' => $instance->initiator_id, + 'status' => $action + ]; + \Webman\RedisQueue\Redis::send('approval-callback', $approvalcallback); + + // 2) 聚合本步结论(any / all) + $typeRaw = strtolower($step['type'] ?? 'all'); + $type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all'; + $outcome = $this->getStepOutcome($instanceId, $round, $idx, $type); // 'pending' | 'approve' | 'reject' + + if ($outcome === 'pending') { + return ['status' => 'step_pending', 'msg' => '审批待定']; + } + + // 3) 有结论:关闭本步其余 pending + $this->closeOtherPending($instanceId, $round, $idx, [$approverId]); + + // 4) 拒绝 => 结束 + if ($outcome === 'reject') { + Db::table('approval_instances')->where('id', $instanceId)->update([ + 'status' => 'rejected', + 'finished_at' => $now, + 'updated_at' => $now, + ]); + $this->sendNotification([$instance->initiator_id], $instance, 'reject', false); + return ['status' => 'completed', 'msg' => '拒绝']; + } + + // 5) 通过 => 进入下一步或结束 + $formData = json_decode($instance->form_data, true) ?: []; + $nextIndex = $this->getNextStepIndex($steps, $formData, $idx); + + if ($nextIndex === null) { + Db::table('approval_instances')->where('id', $instanceId)->update([ + 'status' => 'approved', + 'finished_at' => $now, + 'updated_at' => $now, + ]); + $this->sendNotification([$instance->initiator_id], $instance, 'approve', false); + return ['status' => 'completed', 'msg' => '同意']; + } + + // 乐观推进 current_step + $affected = Db::table('approval_instances') + ->where('id', $instanceId) + ->where('current_step', $idx) + ->where('status', 'processing') + ->update(['current_step' => $nextIndex, 'updated_at' => $now]); + if (!$affected) { + return ['status' => 'noop', 'msg' => '并发进展']; + } + + // 生成下一步待办 + 通知 + $nextStep = $steps[$nextIndex]; + $nextApprovers = $this->resolveApprovers($nextStep, $instance, true); + foreach ($nextApprovers as $uid) { + $this->insertApprovalRecord($instance, $nextIndex, $uid); + } + $this->sendNotification($nextApprovers, $instance, 'pending', false); + $this->notifyCCs($nextStep, $instance, 'pending'); + return ['status' => 'next_step', 'msg' => '进行下一步:'.$nextStep['name'] ?? ('Step '.$nextIndex)]; + }); + } + + /** 撤回(仅发起人在无已处理记录前可撤回) */ + public function revoke(int $instanceId, int $initiatorId, string $reason = '') + { + return Db::transaction(function () use ($instanceId, $initiatorId, $reason) { + $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); + if (!$instance) throw new BusinessException('审批实例未找到'); + if ((int)$instance->initiator_id !== (int)$initiatorId) throw new BusinessException('无权撤回'); + if ($instance->status !== 'processing') throw new BusinessException('流程已结束或不可撤回'); + + $handled = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->whereIn('status', ['approved','rejected','timeout','skipped']) + ->count() > 0; + if ($handled) throw new BusinessException('当前进度不可撤回'); + + $now = date('Y-m-d H:i:s'); + Db::table('approval_instances')->where('id', $instanceId)->update([ + 'status' => 'revoked', + 'finished_at' => $now, + 'updated_at' => $now, + ]); + + Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('status', 'pending') + ->update(['status' => 'skipped', 'approved_at' => $now, 'updated_at' => $now]); + + $this->sendNotification([$initiatorId], $instance, 'revoked', false); + return ['status' => 'completed', 'result' => 'revoked']; + }); + } + + /** 流程图数据(把 skipped/timeout 视作已完结的中性状态) */ + public function getFlowDiagramData(int $instanceId): array + { + $instance = Db::table('approval_instances')->find($instanceId); + if (!$instance) return []; + + $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); + if (!$flow) return []; + + $steps = json_decode($flow->steps, true) ?: []; + $records = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('round', (int)$instance->round) + ->orderBy('step_index')->orderByRaw('COALESCE(approved_at, created_at) asc') + ->get() + ->groupBy('step_index'); + + $formData = json_decode($instance->form_data, true) ?: []; + $data = []; + foreach ($steps as $i => $step) { + $actions = ($records->get($i) ?? collect())->toArray(); + + if (!empty($actions)) { + $cntApproved = 0; $cntRejected = 0; $cntPending = 0; + foreach ($actions as $a) { + if ($a->status === 'approved') $cntApproved++; + elseif ($a->status === 'rejected') $cntRejected++; + elseif ($a->status === 'pending') $cntPending++; + // skipped/timeout 不阻断完结显示 + } + if ($cntRejected > 0) $status = 'rejected'; + elseif ($cntPending > 0) $status = 'processing'; + elseif ($cntApproved > 0) $status = 'approved'; + else $status = 'approved'; // 全是 skipped/timeout 也视为已过 + } else { + if ($i < (int)$instance->current_step) { + if (isset($step['condition']) && !$this->evaluateCondition($step['condition'], $formData)) { + $status = 'skipped'; + } else { + $status = 'approved'; + } + } elseif ($i === (int)$instance->current_step && $instance->status === 'processing') { + $status = 'processing'; + } else { + $status = 'pending'; + } + } + + $data[] = [ + 'index' => $i, + 'name' => $step['name'] ?? ('Step '.$i), + 'type' => $step['type'] ?? 'all', + 'status' => $status, + 'actions' => $actions, + ]; + } + return $data; + } + + /** 定时任务:处理超时(实例级) */ + public function handleTimeouts() + { + $now = date('Y-m-d H:i:s'); + $expired = Db::table('approval_instances') + ->where('status', 'processing') + ->whereNotNull('deadline_at') + ->where('deadline_at', '<=', $now) + ->get(); + + foreach ($expired as $instance) { + Db::transaction(function () use ($instance, $now) { + $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); + if (!$flow) return; + + $final = ($flow->timeout_action === 'auto_approve') ? 'approved' : 'rejected'; + $finalAction = ($flow->timeout_action === 'auto_approve') ? 'approve' : 'reject'; + + Db::table('approval_instances')->where('id', $instance->id)->update([ + 'status' => $final, + 'finished_at' => $now, + 'updated_at' => $now, + ]); + + Db::table('approval_records') + ->where('instance_id', $instance->id) + ->where('round', (int)$instance->round) + ->where('step_index', $instance->current_step) + ->where('status', 'pending') + ->update(['status' => 'timeout', 'approved_at' => $now, 'updated_at' => $now]); + + $this->notifyInitiator($instance, $finalAction); + }); + } + } + + // ---------- 内部工具 ---------- + + /** 解析审批人(role/user/initiator_leader/field_user/dept_head) */ + private function resolveApprovers(array $step, object $instance, bool $avoidSelf = true): array + { + $resolved = []; + $formData = json_decode($instance->form_data ?? '{}', true) ?: []; + + $confs = $step['approvers'] ?? []; + foreach ($confs as $cfg) { + $typ = $cfg['type'] ?? ''; + $val = $cfg['value'] ?? null; + $ids = []; + + switch ($typ) { + case 'role': + $sys = isset($cfg['system_id']) ? (int)$cfg['system_id'] : null; + $includeGlobal = array_key_exists('include_global', $cfg) ? (bool)$cfg['include_global'] : true; + $ids = $this->getUserIdsByRole($val, $sys, true, $includeGlobal); + break; + case 'user': + $ids = [(int)$val]; + break; + case 'initiator_leader': + $leaderId = $this->getLeaderIdByUserId((int)$instance->initiator_id); + if ($leaderId) $ids = [(int)$leaderId]; + break; + case 'field_user': + if (isset($formData[$val])) $ids = [(int)$formData[$val]]; + break; + case 'dept_head': + $head = $this->getDeptHeadId((int)$val); + if ($head) $ids = [(int)$head]; + break; + default: + throw new BusinessException("不支持的审批人类型: {$typ}"); + } + $resolved = array_merge($resolved, $ids); + } + + $resolved = array_unique(array_filter(array_map('intval', $resolved))); + if ($avoidSelf) { + // $resolved = array_values(array_filter($resolved, fn($id) => $id !== (int)$instance->initiator_id)); + $resolved = array_values($resolved); + } + if (empty($resolved)) { + throw new BusinessException("未解析到任何审批人"); + } + return $resolved; + } + + /** 通知抄送人 */ + private function notifyCCs(array $step, object $instance, string $action = 'pending'): void + { + $ccConfig = $step['cc'] ?? []; + if (empty($ccConfig)) return; + + $formData = json_decode($instance->form_data ?? '{}', true) ?: []; + $uids = []; + + foreach ($ccConfig as $cc) { + $typ = $cc['type'] ?? ''; + $val = $cc['value'] ?? null; + + switch ($typ) { + case 'role': + $uids = array_merge($uids, $this->getUserIdsByRole($val, null, true, true)); + break; + case 'user': $uids[] = (int)$val; break; + case 'initiator_leader': + $leaderId = $this->getLeaderIdByUserId((int)$instance->initiator_id); + if ($leaderId) $uids[] = (int)$leaderId; + break; + case 'field_user': + if (isset($formData[$val])) $uids[] = (int)$formData[$val]; + break; + case 'dept_head': + $head = $this->getDeptHeadId((int)$val); + if ($head) $uids[] = (int)$head; + break; + default: break; + } + } + + $uids = array_unique(array_filter(array_map('intval', $uids))); + if (empty($uids)) return; + + $now = date('Y-m-d H:i:s'); + foreach ($uids as $uid) { + $exists = Db::table('approval_ccs') + ->where('instance_id', $instance->id) + ->where('user_id', $uid) + ->count() > 0; + if (!$exists) { + Db::table('approval_ccs')->insert([ + 'instance_id' => $instance->id, + 'user_id' => $uid, + 'notified_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + $this->sendNotification($uids, $instance, $action, true); + } + + /** 条件求值(简单表达式) */ + private function evaluateCondition(?string $condition, array $formData): bool + { + if (!$condition) return true; + $pattern = '/^(\w+)\s*(==|!=|>|<|>=|<=)\s*(.+)$/'; + if (!preg_match($pattern, $condition, $m)) { + throw new BusinessException("条件格式错误: {$condition}"); + } + $field = $m[1]; $op = $m[2]; $value = trim($m[3], "\"'"); + $actual = $formData[$field] ?? null; + if (is_numeric($value) && is_numeric($actual)) { $value=(float)$value; $actual=(float)$actual; } + return match ($op) { + '==' => $actual == $value, + '!=' => $actual != $value, + '>' => $actual > $value, + '<' => $actual < $value, + '>=' => $actual >= $value, + '<=' => $actual <= $value, + default => throw new BusinessException("不支持的操作符: {$op}"), + }; + } + + /** 获取下一个有效步骤索引(考虑 condition) */ + private function getNextStepIndex(array $steps, array $formData, int $currentStepIndex): ?int + { + for ($i = $currentStepIndex + 1; $i < count($steps); $i++) { + $s = $steps[$i]; + if (!isset($s['condition']) || $this->evaluateCondition($s['condition'], $formData)) { + return $i; + } + } + return null; + } + + /** + * 聚合本步骤结论(按类型) + * 返回:'pending' | 'approve' | 'reject' + * + * any:有 reject => reject;否则有 approve => approve;否则 pending + * all:有 reject => reject;有 pending => pending;否则(全 approved 或 approved+skipped/timeout)=> approve + */ + private function getStepOutcome(int $instanceId, int $round, int $stepIndex, string $type): string + { + $records = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('round', $round) // ★ + ->where('step_index', $stepIndex) + ->get(); + + $pending = 0; $approved = 0; $rejected = 0; + foreach ($records as $r) { + if ($r->status === 'pending') $pending++; + elseif ($r->status === 'approved') $approved++; + elseif ($r->status === 'rejected') $rejected++; + } + + $type = strtolower($type); + if ($type === 'any') { + if ($rejected > 0) return 'reject'; + if ($approved > 0) return 'approve'; + return 'pending'; + } + if ($rejected > 0) return 'reject'; + if ($pending > 0) return 'pending'; + return $approved > 0 ? 'approve' : 'pending'; + } + + /** 关闭本步其余待办为 skipped */ + private function closeOtherPending(int $instanceId, int $round, int $stepIndex, array $keepApproverIds = []): void + { + $now = date('Y-m-d H:i:s'); + $q = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('round', $round) // ★ + ->where('step_index', $stepIndex) + ->where('status', 'pending'); + + if (!empty($keepApproverIds)) $q->whereNotIn('approver_id', $keepApproverIds); + + $q->update(['status'=>'skipped','approved_at'=>$now,'updated_at'=>$now]); + } + + /** 创建待办(去重) */ + private function insertApprovalRecord(object $instance, int $stepIndex, int $approverId): void + { + $round = (int)($instance->round ?? 1); + $now = date('Y-m-d H:i:s'); + + $exists = Db::table('approval_records') + ->where('instance_id', (int)$instance->id) + ->where('round', $round) // ★ 限当前轮 + ->where('step_index', $stepIndex) + ->where('approver_id', $approverId) + ->where('status', 'pending') + ->exists(); + + if ($exists) return; + + Db::table('approval_records')->insert([ + 'instance_id' => (int)$instance->id, + 'approver_id' => $approverId, + 'step_index' => $stepIndex, + 'round' => $round, // ★ + 'action' => null, + 'status' => 'pending', + 'comment' => null, + 'approved_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + /** 发送通知(支持附加文案:提交说明/申诉/讨论摘要) */ + private function sendNotification(array $userIds, object $instance, string $action, bool $isCC, array $extra = []): void + { + $map = [ + 'pending' => '待审批', + 'approve' => '已通过', + 'reject' => '已拒绝', + 'revoked' => '已撤回', + ]; + $prefix = $isCC ? '【抄送】' : '【审批】'; + $text = $map[$action] ?? $action; + + // 业务标题/内容 + $approvalType = array_flip(\app\common\service\SysDictService::pluckKeyValueByCode('approval_type')); + + switch ($instance->flow_type) { + case 'stock_orders': + // 备货单 + $stockOrder = \app\common\model\StockOrders::find($instance->biz_id); + if ($stockOrder) { + $title = $prefix."备货单:{$stockOrder->order_no}"; + $flowTxt = $approvalType[$instance->flow_type] ?? $instance->flow_type; + $content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务单号 {$stockOrder->order_no}"; + } + break; + case 'stock_orders_nullify': + // 备货单作废 + break; + case 'purchase_orders': + // 采购单 + $purchaseOrder = \app\common\model\PurchaseOrders::find($instance->biz_id); + if ($purchaseOrder) { + $title = $prefix."采购单:{$purchaseOrder->order_no}"; + $flowTxt = $approvalType[$instance->flow_type] ?? $instance->flow_type; + $content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务单号 {$purchaseOrder->order_no}"; + } + break; + case 'purchase_order': + // 采购单 + break; + } + + // 附加说明(提交说明/申诉/讨论摘要) + $append = $extra['appeal'] ?? ($extra['note'] ?? null); + if ($append) { + if (function_exists('mb_strimwidth')) { + $append = mb_strimwidth($append, 0, 120, '...', 'UTF-8'); + } + $content .= ";说明:{$append}"; + } + + noticePush([ + 'title' => $title, + 'content' => $content, + 'recipient' => $userIds, + 'email' => 1, + ]); + } + + + private function notifyInitiator(object $instance, string $result): void + { + $this->sendNotification([(int)$instance->initiator_id], $instance, $result, false); + } + + // ---------- 数据访问(roles / user_roles 结构) ---------- + + /** + * 从角色取用户ID + * @param mixed $roleIdOrName 数字 => role_id;字符串 => roles.name + * @param int|null $systemId 限定 roles.system_id;null 不限定 + * @param bool $onlyActive 仅取 status=1 角色,默认 true + * @param bool $includeGlobal 是否包含全局角色(system_id=0),默认 true + */ + private function getUserIdsByRole($roleIdOrName, ?int $systemId = null, bool $onlyActive = true, bool $includeGlobal = true): array + { + if (is_int($roleIdOrName) || (is_string($roleIdOrName) && ctype_digit($roleIdOrName))) { + $roleId = (int)$roleIdOrName; + $needJoin = $onlyActive || !is_null($systemId) || $includeGlobal === false; + + if (!$needJoin) { + $rows = Db::table('user_roles')->where('role_id', $roleId)->select('user_id')->get(); + } else { + $q = Db::table('user_roles as ur') + ->join('roles as r', 'ur.role_id', '=', 'r.id') + ->where('ur.role_id', $roleId) + ->select('ur.user_id'); + if ($onlyActive) $q->where('r.status', 1); + if (!is_null($systemId)) { + $includeGlobal ? $q->whereIn('r.system_id', [0, $systemId]) : $q->where('r.system_id', $systemId); + } + $rows = $q->get(); + } + } else { + $name = (string)$roleIdOrName; + $q = Db::table('user_roles as ur') + ->join('roles as r', 'ur.role_id', '=', 'r.id') + ->where('r.name', $name) + ->select('ur.user_id'); + if ($onlyActive) $q->where('r.status', 1); + if (!is_null($systemId)) { + $includeGlobal ? $q->whereIn('r.system_id', [0, $systemId]) : $q->where('r.system_id', $systemId); + } + $rows = $q->get(); + } + + $ids = []; + foreach ($rows as $row) $ids[] = (int)$row->user_id; + return array_values(array_unique($ids)); + } + + private function getLeaderIdByUserId(int $userId): ?int + { + $id = Db::table('user')->where('id', $userId)->value('report_to'); + return $id ? (int)$id : null; + } + + private function getDeptHeadId(int $deptId): ?int + { + $id = Db::table('departments')->where('id', $deptId)->value('head_user_id'); + return $id ? (int)$id : null; + } + + + /** + * 查询某个审批实例的审批记录(附带步骤名/类型,可选带出审批人姓名) + * + * @param int $instanceId + * @param string|null $userNameColumn 可选:如 'name' 或 'nickname'。传 null 则不 join 用户表 + * @return array{ + * instance: array, + * steps: array, + * records: array, + * summary: array{approved:int,rejected:int,pending:int,skipped:int,timeout:int,total:int} + * } + */ + public function getInstanceRecords(int $instanceId, ?string $userNameColumn = null): array + { + // 1) 实例 + 流程步骤 + $instance = Db::table('approval_instances')->where('id', $instanceId)->first(); + if (!$instance) { + throw new BusinessException('审批实例未找到'); + } + $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); + if (!$flow) { + throw new BusinessException('审批流程定义未找到'); + } + $steps = json_decode($flow->steps, true) ?: []; + + // 2) 记录查询(可选带出审批人姓名) + if ($userNameColumn) { + // 注意:确保 user 表存在该列 + $records = Db::table('approval_records as ar') + ->leftJoin('user as u', 'ar.approver_id', '=', 'u.id') + ->where('ar.instance_id', $instanceId) + ->orderBy('ar.step_index') + ->orderByRaw('COALESCE(ar.approved_at, ar.created_at) asc') + ->select( + 'ar.*', + Db::raw("u.`{$userNameColumn}` as approver_name") + ) + ->get() + ->toArray(); + } else { + $records = Db::table('approval_records as ar') + ->where('ar.instance_id', $instanceId) + ->orderBy('ar.step_index') + ->orderByRaw('COALESCE(ar.approved_at, ar.created_at) asc') + ->select('ar.*') + ->get() + ->toArray(); + } + + // 3) 附加步骤名/类型;统计汇总 + $summary = ['approved'=>0,'rejected'=>0,'pending'=>0,'skipped'=>0,'timeout'=>0,'total'=>0]; + foreach ($records as &$r) { + $idx = (int)$r->step_index; + $meta = $steps[$idx] ?? []; + $r->step_name = $meta['name'] ?? ('Step '.$idx); + $r->step_type = $meta['type'] ?? 'all'; + + if (isset($summary[$r->status])) { + $summary[$r->status]++; + } + $summary['total']++; + } + unset($r); + + return [ + 'instance' => [ + 'id' => (int)$instance->id, + 'flow_type' => $instance->flow_type, + 'biz_type' => $instance->biz_type, + 'biz_id' => $instance->biz_id, + 'status' => $instance->status, + 'current_step' => (int)$instance->current_step, + 'created_at' => $instance->created_at, + 'updated_at' => $instance->updated_at, + 'finished_at' => $instance->finished_at, + ], + 'steps' => array_map(function($s){ + return [ + 'name' => $s['name'] ?? '', + 'type' => $s['type'] ?? 'all', + 'condition' => $s['condition'] ?? null, + ]; + }, $steps), + 'records' => array_map(function($r){ + // 转成数组(Illuminate 返回的是 stdClass) + return [ + 'id' => (int)$r->id, + 'instance_id' => (int)$r->instance_id, + 'approver_id' => (int)$r->approver_id, + 'step_index' => (int)$r->step_index, + 'action' => $r->action, + 'status' => $r->status, + 'comment' => $r->comment, + 'approved_at' => $r->approved_at, + 'created_at' => $r->created_at, + 'updated_at' => $r->updated_at, + 'approver_name'=> property_exists($r, 'approver_name') ? $r->approver_name : null, + 'step_name' => $r->step_name, + 'step_type' => $r->step_type, + ]; + }, $records), + 'summary' => $summary, + ]; + } + + /** + * 极简时间轴(含每步时间 & 申请人信息) + * + * @param int $instanceId + * @param string|null $userNameColumn 申请人姓名列(如 'name' / 'nickname')。不需要就传 null + * @param string $initiatorPlacement 'prepend' | 'attach' + * - 'prepend':在最前插入一条“发起申请”块(推荐) + * - 'attach' :把申请人信息并到第一步的返回项里(不新增块) + * + * 返回:按显示顺序的数组,每项: + * { + * index:int // 步骤索引;若为“发起申请”块则为 -1 + * name:string // 步骤名或“发起申请” + * type:string // any|all 或 '-'(发起块) + * status:string // approved|rejected|processing|pending|skipped|timeout|submitted(发起块) + * started_at:?string + * finished_at:?string + * duration_seconds:?int + * // 仅发起块或 attach 时的第一步会包含: + * initiator_id?:int + * initiator_name?:?string + * } + */ + public function getSimpleTimeline2(int $instanceId, ?string $userNameColumn = null, string $initiatorPlacement = 'prepend'): array + { + // 基础数据 + $instance = Db::table('approval_instances')->where('id', $instanceId)->first(); + if (!$instance) throw new BusinessException('审批实例未找到'); + + $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); + if (!$flow) throw new BusinessException('审批流程定义未找到'); + + $steps = json_decode($flow->steps, true) ?: []; + $formData = json_decode($instance->form_data ?? '{}', true) ?: []; + $curStep = (int)$instance->current_step; + $round = (int)$instance->round; + + // 申请人 + $initiatorId = (int)$instance->initiator_id; + $initiatorName = null; + if ($userNameColumn) { + $initiatorName = Db::table('user')->where('id', $initiatorId)->value($userNameColumn); + } + + // 审批记录 + $records = Db::table('approval_records as ar') + ->where('ar.instance_id', $instanceId) + ->where('ar.round', $round) + ->orderBy('ar.step_index') + ->orderByRaw('COALESCE(ar.approved_at, ar.created_at) asc') + ->select('ar.*') + ->get() + ->toArray(); + + $byStep = []; + foreach ($records as $r) { + $idx = (int)$r->step_index; + $byStep[$idx][] = $r; + } + + $timeline = []; + + // 可选:先插入“发起申请”块(index = -1) + if ($initiatorPlacement === 'prepend') { + $appliedAt = $instance->created_at ?: null; + $timeline[] = [ + 'index' => -1, + 'name' => '发起申请', + 'type' => '-', + 'status' => 'submitted', + 'started_at' => $appliedAt, + 'finished_at' => $appliedAt, + 'duration_seconds' => 0, + 'initiator_id' => $initiatorId, + 'initiator_name' => $initiatorName, + ]; + } + + // 每一步 + foreach ($steps as $i => $meta) { + $name = $meta['name'] ?? ('Step '.$i); + $typeRaw = strtolower($meta['type'] ?? 'all'); + $type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all'; + $condStr = $meta['condition'] ?? null; + $condOk = $condStr ? $this->evaluateCondition($condStr, $formData) : true; + + $recs = $byStep[$i] ?? []; + $hasRecs = !empty($recs); + + $status = 'pending'; + $startedAt = null; + $finishedAt = null; + $durationSec = null; + + if ($hasRecs) { + // 统计时间 + $minCreated = null; + $minApproveAt = null; $maxApproveAt = null; + $minRejectAt = null; + $maxTimeoutAt = null; + $cnt = ['pending'=>0,'approved'=>0,'rejected'=>0,'timeout'=>0]; + + foreach ($recs as $rr) { + if ($rr->created_at && (!$minCreated || $rr->created_at < $minCreated)) $minCreated = $rr->created_at; + if ($rr->approved_at) { + if ($rr->status === 'approved') { + if (!$minApproveAt || $rr->approved_at < $minApproveAt) $minApproveAt = $rr->approved_at; + if (!$maxApproveAt || $rr->approved_at > $maxApproveAt) $maxApproveAt = $rr->approved_at; + } elseif ($rr->status === 'rejected') { + if (!$minRejectAt || $rr->approved_at < $minRejectAt) $minRejectAt = $rr->approved_at; + } elseif ($rr->status === 'timeout') { + if (!$maxTimeoutAt || $rr->approved_at > $maxTimeoutAt) $maxTimeoutAt = $rr->approved_at; + } + } + if (isset($cnt[$rr->status])) $cnt[$rr->status]++; + } + + $startedAt = $minCreated; + + // 总结果 + $outcome = $this->getStepOutcome($instanceId, $round, $i, $type); // 'pending'|'approve'|'reject' + if ($outcome === 'reject') { + $status = 'rejected'; + $finishedAt = $minRejectAt; + } elseif ($outcome === 'approve') { + $status = 'approved'; + $finishedAt = ($type === 'any') ? $minApproveAt : $maxApproveAt; + } else { + if ($cnt['timeout'] > 0 && $cnt['approved'] === 0 && $cnt['rejected'] === 0 && $cnt['pending'] === 0) { + $status = 'timeout'; + $finishedAt = $maxTimeoutAt; + } else { + $status = 'processing'; + } + } + + if ($startedAt && $finishedAt) { + $durationSec = max(0, strtotime($finishedAt) - strtotime($startedAt)); + } + } else { + // 无记录:根据位置与条件展示 + if ($i < $curStep) { + $status = $condOk ? 'approved' : 'skipped'; + } elseif ($i === $curStep && $instance->status === 'processing') { + $status = 'processing'; + } else { + $status = $condOk ? 'pending' : 'skipped'; + } + } + + $row = [ + 'index' => (int)$i, + 'name' => $name, + 'type' => $type, + 'status' => $status, + 'started_at' => $startedAt, + 'finished_at' => $finishedAt, + 'duration_seconds' => $durationSec, + ]; + + // 如果需要把申请人信息“并到第一步”而不是独立一块 + if ($initiatorPlacement === 'attach' && empty($timeline)) { + $row['initiator_id'] = $initiatorId; + $row['initiator_name'] = $initiatorName; + } + + $timeline[] = $row; + } + + return $timeline; + } + + + public function getSimpleTimeline(int $instanceId, ?string $userNameColumn = null, string $initiatorPlacement = 'prepend'): array + { + // 基础数据 + $instance = Db::table('approval_instances')->where('id', $instanceId)->first(); + if (!$instance) throw new BusinessException('审批实例未找到'); + + $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); + if (!$flow) throw new BusinessException('审批流程定义未找到'); + + $steps = json_decode($flow->steps, true) ?: []; + $formData = json_decode($instance->form_data ?? '{}', true) ?: []; + $curStep = (int)$instance->current_step; + $round = (int)$instance->round; + + // 申请人 + $initiatorId = (int)$instance->initiator_id; + $initiatorName = null; + if ($userNameColumn) { + $initiatorName = Db::table('user')->where('id', $initiatorId)->value($userNameColumn); + } + + // 审批记录(仅当前轮次) + $records = Db::table('approval_records as ar') + ->where('ar.instance_id', $instanceId) + ->where('ar.round', $round) + ->orderBy('ar.step_index') + ->orderByRaw('COALESCE(ar.approved_at, ar.created_at) asc') + ->select('ar.*') + ->get() + ->toArray(); + + // 一次性拉取姓名,避免 N+1 + $nameMap = []; + if ($userNameColumn && !empty($records)) { + $uids = []; + foreach ($records as $r) $uids[] = (int)$r->approver_id; + $uids = array_values(array_unique($uids)); + if (!empty($uids)) { + $pairs = Db::table('user')->whereIn('id', $uids)->pluck($userNameColumn, 'id')->toArray(); + foreach ($pairs as $uid => $name) $nameMap[(int)$uid] = $name; + } + } + + // 记录分组 + $byStep = []; + foreach ($records as $r) { + $idx = (int)$r->step_index; + $byStep[$idx][] = $r; + } + + $timeline = []; + + // 可选:发起申请块 + if ($initiatorPlacement === 'prepend') { + $appliedAt = $instance->created_at ?: null; + $timeline[] = [ + 'index' => -1, + 'name' => '发起申请', + 'type' => '-', + 'status' => 'submitted', + 'started_at' => $appliedAt, + 'finished_at' => $appliedAt, + 'duration_seconds' => 0, + 'initiator_id' => $initiatorId, + 'initiator_name' => $initiatorName, + ]; + } + + // 每一步 + foreach ($steps as $i => $meta) { + $name = $meta['name'] ?? ('Step '.$i); + $typeRaw = strtolower($meta['type'] ?? 'all'); + $type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all'; + $condStr = $meta['condition'] ?? null; + $condOk = $condStr ? $this->evaluateCondition($condStr, $formData) : true; + + $recs = $byStep[$i] ?? []; + $hasRecs = !empty($recs); + + $status = 'pending'; + $startedAt = null; + $finishedAt = null; + $durationSec = null; + + // ★ 每一步的“审批意见”(只展示有内容的 comment) + $opinions = []; // [{approver_id, approver_name?, action, status, comment, time}...] + + if ($hasRecs) { + // 统计时间 + $minCreated = null; + $minApproveAt = null; $maxApproveAt = null; + $minRejectAt = null; + $maxTimeoutAt = null; + $cnt = ['pending'=>0,'approved'=>0,'rejected'=>0,'timeout'=>0]; + + foreach ($recs as $rr) { + // 收集“审批意见”(非空 comment 都算) + $cmt = trim((string)($rr->comment ?? '')); + if ($cmt !== '') { + $opinions[] = [ + 'approver_id' => (int)$rr->approver_id, + 'approver_name' => $userNameColumn ? ($nameMap[(int)$rr->approver_id] ?? null) : null, + 'action' => $rr->action, // approve | reject | null + 'status' => $rr->status, // approved|rejected|pending|skipped|timeout + 'comment' => $cmt, + 'time' => $rr->approved_at ?: $rr->created_at, + ]; + } + + if ($rr->created_at && (!$minCreated || $rr->created_at < $minCreated)) $minCreated = $rr->created_at; + if ($rr->approved_at) { + if ($rr->status === 'approved') { + if (!$minApproveAt || $rr->approved_at < $minApproveAt) $minApproveAt = $rr->approved_at; + if (!$maxApproveAt || $rr->approved_at > $maxApproveAt) $maxApproveAt = $rr->approved_at; + } elseif ($rr->status === 'rejected') { + if (!$minRejectAt || $rr->approved_at < $minRejectAt) $minRejectAt = $rr->approved_at; + } elseif ($rr->status === 'timeout') { + if (!$maxTimeoutAt || $rr->approved_at > $maxTimeoutAt) $maxTimeoutAt = $rr->approved_at; + } + } + if (isset($cnt[$rr->status])) $cnt[$rr->status]++; + } + + // 时间升序排序 opinions + if (!empty($opinions)) { + usort($opinions, function($a, $b) { + return strcmp($a['time'] ?? '', $b['time'] ?? ''); + }); + } + + $startedAt = $minCreated; + + // 总结果(注意:这里按你现在的签名调用,含 round) + $outcome = $this->getStepOutcome($instanceId, $round, $i, $type); // 'pending'|'approve'|'reject' + if ($outcome === 'reject') { + $status = 'rejected'; + $finishedAt = $minRejectAt; + } elseif ($outcome === 'approve') { + $status = 'approved'; + $finishedAt = ($type === 'any') ? $minApproveAt : $maxApproveAt; + } else { + if ($cnt['timeout'] > 0 && $cnt['approved'] === 0 && $cnt['rejected'] === 0 && $cnt['pending'] === 0) { + $status = 'timeout'; + $finishedAt = $maxTimeoutAt; + } else { + $status = 'processing'; + } + } + + if ($startedAt && $finishedAt) { + $durationSec = max(0, strtotime($finishedAt) - strtotime($startedAt)); + } + } else { + // 无记录:根据位置与条件展示 + if ($i < $curStep) { + $status = $condOk ? 'approved' : 'skipped'; + } elseif ($i === $curStep && $instance->status === 'processing') { + $status = 'processing'; + } else { + $status = $condOk ? 'pending' : 'skipped'; + } + } + + $row = [ + 'index' => (int)$i, + 'name' => $name, + 'type' => $type, + 'status' => $status, + 'started_at' => $startedAt, + 'finished_at' => $finishedAt, + 'duration_seconds' => $durationSec, + 'opinions' => $opinions, // ★ 这就是每个审批意见 + ]; + + // 把申请人信息并到第一步(可选) + if ($initiatorPlacement === 'attach' && empty($timeline)) { + $row['initiator_id'] = $initiatorId; + $row['initiator_name'] = $initiatorName; + } + + $timeline[] = $row; + } + + return $timeline; + } + + + /** + * 按业务ID获取“最新进展”的极简状态 + * - 已完结:直接返回实例最终状态 + * - 进行中:返回当前步骤(索引、名称、类型)与当前待处理审批者列表(pending) + * + * @param string $bizId + * @param string|null $bizType 可选限定业务类型 + * @param string|null $userNameColumn 可选:审批人姓名字段(如 user.name / nickname) + * @return array{ + * instance_id:int, flow_type:string, biz_type:string, biz_id:string, + * status:string, // 实例状态:approved|rejected|revoked|processing + * finished_at:?string, updated_at:?string, + * step?: array{ + * index:int, name:string, type:string, // any|all + * status:string, // processing|timeout(进行中场景) + * pending_approvers: array + * } + * } + */ + public function getBriefStatusByBiz(string $bizId, ?string $bizType = null, ?string $userNameColumn = null): array + { + // 1) 最新实例 + $q = Db::table('approval_instances')->where('biz_id', $bizId); + if ($bizType !== null && $bizType !== '') { + $q->where('biz_type', $bizType); + } + $instance = $q->orderByDesc('id')->first(); + if (!$instance) { + // throw new BusinessException('未找到对应的审批实例'); + return []; + } + + $base = [ + 'instance_id' => (int)$instance->id, + 'flow_type' => $instance->flow_type, + 'biz_type' => $instance->biz_type, + 'biz_id' => $instance->biz_id, + 'status' => $instance->status, + 'finished_at' => $instance->finished_at, + 'updated_at' => $instance->updated_at, + ]; + + // 2) 已完结:直接返回 + if ($instance->status !== 'processing') { + return $base; + } + + // 3) 进行中:取当前步骤 + 当前 pending 审批者 + $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); + if (!$flow) { + // 容错:没有流程定义 + return $base; + } + $steps = json_decode($flow->steps, true) ?: []; + $idx = (int)$instance->current_step; + $round = (int)$instance->round; + $meta = $steps[$idx] ?? []; + + $typeRaw = strtolower($meta['type'] ?? 'all'); + $type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all'; + + // 当前步的所有记录 + $recsQ = Db::table('approval_records')->where('instance_id', $instance->id)->where('step_index', $idx); + + // 待处理人(只列出 pending) + if ($userNameColumn) { + $pendingRows = Db::table('approval_records as ar') + ->leftJoin('user as u', 'ar.approver_id', '=', 'u.id') + ->where('ar.instance_id', $instance->id) + ->where('ar.round', $round) + ->where('ar.step_index', $idx) + ->where('ar.status', 'pending') + ->select('ar.approver_id', Db::raw("u.`{$userNameColumn}` as approver_name")) + ->get() + ->toArray(); + } else { + $pendingRows = Db::table('approval_records') + ->where('instance_id', $instance->id) + ->where('round', $round) + ->where('step_index', $idx) + ->where('status', 'pending') + ->select('approver_id') + ->get() + ->toArray(); + } + + $pendingApprovers = []; + foreach ($pendingRows as $r) { + $row = ['approver_id' => (int)$r->approver_id]; + if ($userNameColumn && property_exists($r, 'approver_name')) { + $row['approver_name'] = $r->approver_name; + } + $pendingApprovers[] = $row; + } + + // 当前步的聚合状态(进行中通常返回 processing;若全被超时任务置为 timeout,则返回 timeout) + // 先快速统计是否纯 timeout + $stats = Db::table('approval_records') + ->where('instance_id', $instance->id) + ->where('round', $round) + ->where('step_index', $idx) + ->selectRaw("SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) as c_pending") + ->selectRaw("SUM(CASE WHEN status='approved' THEN 1 ELSE 0 END) as c_approved") + ->selectRaw("SUM(CASE WHEN status='rejected' THEN 1 ELSE 0 END) as c_rejected") + ->selectRaw("SUM(CASE WHEN status='timeout' THEN 1 ELSE 0 END) as c_timeout") + ->first(); + + $stepStatus = 'processing'; + if ($stats && (int)$stats->c_timeout > 0 && (int)$stats->c_pending === 0 && (int)$stats->c_approved === 0 && (int)$stats->c_rejected === 0) { + $stepStatus = 'timeout'; + } + + $base['step'] = [ + 'index' => $idx, + 'name' => $meta['name'] ?? ('Step '.$idx), + 'type' => $type, // any|all + 'status' => $stepStatus, // processing|timeout + 'pending_approvers' => $pendingApprovers, // 当前正在等待的审批者 + ]; + + return $base; + } + + // ====== ====== ====== ====== ====== ====== 重提操作 && 讨论 ====== ====== ====== ====== ====== ====== + + /** + * 重提交(仅对 status=rejected 的实例;支持申诉说明) + * - 合并 $patchData 到 form_data,并设置 resubmitted=1 + * - 若 $appealText 非空:form_data.appeal_last = $appealText(可用于前端展示) + * - 重新计算首个命中的步骤并创建待办;通知审批人且带上申诉说明 + */ + public function resubmit(int $instanceId, int $initiatorId, array $patchData = [], string $appealText = ''): array + { + return Db::transaction(function () use ($instanceId, $initiatorId, $patchData, $appealText) { + $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); + if (!$instance) throw new BusinessException('审批实例未找到'); + if ((int)$instance->initiator_id !== (int)$initiatorId) throw new BusinessException('仅发起人可重提'); + if ($instance->status !== 'rejected') throw new BusinessException('仅被驳回的实例可重提'); + + $flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first(); + if (!$flow) throw new BusinessException('审批流程定义未找到'); + + $steps = json_decode($flow->steps, true) ?: []; + $formData = json_decode($instance->form_data ?? '{}', true) ?: []; + + // 合并改动 & 打上重提标记 + $formData = array_merge($formData, $patchData); + $formData['resubmitted'] = 1; + + $appealText = trim((string)$appealText); + if ($appealText !== '') { + $formData['appeal_last'] = $appealText; + } + + // 计算重提后首个命中的步骤 + $firstIndex = $this->getNextStepIndex($steps, $formData, -1); + if ($firstIndex === null) throw new BusinessException('重提后未找到可执行的步骤'); + + $now = date('Y-m-d H:i:s'); + $deadlineAt = null; + if (($flow->timeout_hours ?? 0) > 0) { + $deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600); + } + + // 清理可能遗留的 pending + Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('status', 'pending') + ->update(['status'=>'skipped','approved_at'=>$now,'updated_at'=>$now]); + + // ★ 轮次 +1 + $newRound = ((int)$instance->round) + 1; + + // 重新开启实例 + Db::table('approval_instances')->where('id', $instanceId)->update([ + 'status' => 'processing', + 'current_step' => $firstIndex, + 'round' => $newRound, + 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'finished_at' => null, + 'deadline_at' => $deadlineAt, + 'updated_at' => $now, + ]); + + // 创建待办 + 通知 + $firstStep = $steps[$firstIndex]; + $instanceLite = (object)[ + 'id' => (int)$instanceId, + 'flow_type' => (string)$instance->flow_type, + 'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'initiator_id' => (int)$instance->initiator_id, + 'biz_type' => (string)$instance->biz_type, + 'biz_id' => (string)$instance->biz_id, + 'round' => $newRound, + ]; + $approvers = $this->resolveApprovers($firstStep, $instanceLite, true); + if (empty($approvers)) throw new BusinessException('重提后首步未解析到审批人'); + + foreach ($approvers as $uid) { + $this->insertApprovalRecord($instanceLite, $firstIndex, (int)$uid); + } + + $extra = $appealText !== '' ? ['appeal' => $appealText] : []; + $this->sendNotification($approvers, $instanceLite, 'pending', false, $extra); + $this->notifyCCs($firstStep, $instanceLite, 'pending'); + + return [ + 'status' => 'resubmitted', + 'instance_id' => (int)$instanceId, + 'current_step' => $firstIndex, + 'step_name' => $firstStep['name'] ?? ('Step '.$firstIndex), + ]; + }); + } + + + /** 是否该实例参与者(发起人/任一步审批人/抄送人) */ + private function isParticipantOfInstance(int $instanceId, int $userId): bool + { + $inst = Db::table('approval_instances')->where('id', $instanceId)->first(); + if (!$inst) return false; + if ((int)$inst->initiator_id === $userId) return true; + + $inRecords = Db::table('approval_records') + ->where('instance_id', $instanceId)->where('approver_id', $userId)->exists(); + if ($inRecords) return true; + + $inCc = Db::table('approval_ccs') + ->where('instance_id', $instanceId)->where('user_id', $userId)->exists(); + return $inCc; + } + + /** 是否该实例任一步的审批人 */ + private function isApproverOfInstance(int $instanceId, int $userId): bool + { + return Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('approver_id', $userId) + ->exists(); + } + + /** 获取讨论受众:公开=全体参与者;内部=仅审批人(排除自己) */ + private function getDiscussionAudienceUserIds(int $instanceId, bool $approversOnly, int $excludeUserId): array + { + $uids = []; + if (!$approversOnly) { + $inst = Db::table('approval_instances')->where('id', $instanceId)->first(); + if ($inst) $uids[] = (int)$inst->initiator_id; + $ccs = Db::table('approval_ccs')->where('instance_id', $instanceId)->pluck('user_id')->toArray(); + foreach ($ccs as $u) $uids[] = (int)$u; + } + $approvers = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->distinct()->pluck('approver_id')->toArray(); + foreach ($approvers as $u) $uids[] = (int)$u; + + $uids = array_values(array_unique(array_filter($uids, fn($u) => $u !== $excludeUserId))); + return $uids; + } + + /** + * 发表讨论 + * @param int $instanceId + * @param int $userId + * @param string $content + * @param bool $isInternal true=仅审批人可见;false=所有参与者可见 + * @param array $attachments 可选附件数组 + * @param string|null $userNameColumn 通知里带发言人姓名(如 'name'),不需要传 null + */ + public function postDiscussion( + int $instanceId, + int $userId, + string $content, + bool $isInternal = false, + array $attachments = [], + ?string $userNameColumn = null + ): array { + $content = trim($content); + if ($content === '') throw new BusinessException('讨论内容不能为空'); + if (!$this->isParticipantOfInstance($instanceId, $userId)) { + throw new BusinessException('无权参与该实例的讨论'); + } + if ($isInternal && !$this->isApproverOfInstance($instanceId, $userId)) { + throw new BusinessException('仅审批人可发布内部讨论'); + } + + $now = date('Y-m-d H:i:s'); + $id = Db::table('approval_discussions')->insertGetId([ + 'instance_id' => $instanceId, + 'user_id' => $userId, + 'is_internal' => $isInternal ? 1 : 0, + 'content' => $content, + 'attachments' => empty($attachments) ? null : json_encode($attachments, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + // 通知其他人 + $inst = Db::table('approval_instances')->where('id', $instanceId)->first(); + if ($inst) { + $audience = $this->getDiscussionAudienceUserIds($instanceId, $isInternal, $userId); + // 发言人姓名(可选) + $speaker = (string)$userId; + if ($userNameColumn) { + $name = Db::table('user')->where('id', $userId)->value($userNameColumn); + if ($name) $speaker = $name; + } + $snippet = function_exists('mb_strimwidth') ? mb_strimwidth($content, 0, 80, '...', 'UTF-8') : $content; + $extra = ['note' => "【讨论】{$speaker}:{$snippet}"]; + + $this->sendNotification( + $audience, + (object)[ + 'id' => (int)$inst->id, + 'flow_type' => (string)$inst->flow_type, + 'initiator_id' => (int)$inst->initiator_id, + 'form_data' => $inst->form_data, + 'biz_type' => (string)$inst->biz_type, + 'biz_id' => (string)$inst->biz_id, + ], + 'pending', + false, + $extra + ); + } + + return [ + 'id' => (int)$id, + 'instance_id' => $instanceId, + 'user_id' => $userId, + 'is_internal' => (bool)$isInternal, + 'content' => $content, + 'attachments' => $attachments, + 'created_at' => $now, + ]; + } + + /** + * 获取讨论列表 + * @param int $instanceId + * @param int|null $afterId 翻页:取 id > afterId 的新消息 + * @param int $limit + * @param string|null $userNameColumn 带出用户名(如 'name'),不需要可传 null + */ + public function listDiscussions(int $instanceId, ?int $afterId = null, int $limit = 50, ?string $userNameColumn = null): array + { + $q = Db::table('approval_discussions as d')->where('d.instance_id', $instanceId); + if ($afterId) $q->where('d.id', '>', $afterId); + $q->orderBy('d.id', 'asc')->limit(max(1, min(200, $limit))); + + if ($userNameColumn) { + $rows = $q->leftJoin('user as u', 'd.user_id', '=', 'u.id') + ->select('d.*', Db::raw("u.`{$userNameColumn}` as user_name")) + ->get()->toArray(); + } else { + $rows = $q->select('d.*')->get()->toArray(); + } + + return array_map(function($r){ + return [ + 'id' => (int)$r->id, + 'instance_id' => (int)$r->instance_id, + 'user_id' => (int)$r->user_id, + 'is_internal' => (bool)$r->is_internal, + 'content' => $r->content, + 'attachments' => $r->attachments ? json_decode($r->attachments, true) : [], + 'created_at' => $r->created_at, + 'updated_at' => $r->updated_at, + 'user_name' => property_exists($r, 'user_name') ? $r->user_name : null, + ]; + }, $rows); + } + + // ====== ====== ====== ====== ====== ====== 转交操作 ====== ====== ====== ====== ====== ====== + + /** + * 转交当前待办给他人 + * 仅允许【当前这条待办的审批人】把自己名下且为 pending 的记录转交给 toUserId。 + * - 若目标人在本步已有 pending:合并(把本记录标记为 skipped,保留目标人的那条) + * - 否则:原地把该记录的 approver_id 改为 toUserId + * - 写入转交日志 + * - 通知被转交人 + */ + public function transferApproval2(int $instanceId, int $stepIndex, int $fromUserId, int $toUserId, string $reason = ''): array + { + if ($fromUserId === $toUserId) { + return ['status' => 'noop', 'msg' => '目标与来源相同,无需转交']; + } + + return Db::transaction(function () use ($instanceId, $stepIndex, $fromUserId, $toUserId, $reason) { + // 锁实例,避免并发推进 + $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); + if (!$instance) throw new BusinessException('审批实例未找到'); + if ($instance->status !== 'processing') throw new BusinessException('流程非进行中,无法转交'); + + // 锁定来源人的 pending 记录(必须是“自己名下”的记录) + $record = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('round', (int)$instance->round) + ->where('step_index', $stepIndex) + ->where('approver_id', $fromUserId) + ->where('status', 'pending') + ->lockForUpdate() + ->first(); + + if (!$record) { + throw new BusinessException('未找到可转交的待办或已处理'); + } + + $now = date('Y-m-d H:i:s'); + + // 若目标人在本步已有 pending,合并:本记录设为 skipped,保留目标那条 + $target = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('round', (int)$instance->round) + ->where('step_index', $stepIndex) + ->where('approver_id', $toUserId) + ->where('status', 'pending') + ->lockForUpdate() + ->first(); + + if ($target) { + Db::table('approval_records')->where('id', $record->id)->update([ + 'status' => 'skipped', + 'approved_at' => $now, + 'updated_at' => $now, + 'comment' => trim(($record->comment ?? '')."\n[转交给 {$toUserId},目标已有待办,已合并]"), + ]); + $effectiveRecordId = (int)$target->id; + } else { + // 原地替换 approver_id + Db::table('approval_records')->where('id', $record->id)->update([ + 'approver_id' => $toUserId, + 'updated_at' => $now, + 'comment' => trim(($record->comment ?? '')."\n[自 {$fromUserId} 转交]"), + ]); + $effectiveRecordId = (int)$record->id; + } + + // 写转交日志 + Db::table('approval_transfers')->insert([ + 'instance_id' => (int)$instanceId, + 'step_index' => (int)$stepIndex, + 'record_id' => $effectiveRecordId, + 'from_user_id' => (int)$fromUserId, + 'to_user_id' => (int)$toUserId, + 'reason' => $reason, + 'created_at' => $now, + ]); + + // 通知被转交人(沿用你的 sendNotification) + $this->sendNotification([$toUserId], (object)[ + 'id' => (int)$instance->id, + 'flow_type' => (string)$instance->flow_type, + 'biz_type' => (string)$instance->biz_type, + 'biz_id' => (string)$instance->biz_id, + 'initiator_id' => (int)$instance->initiator_id, + 'form_data' => $instance->form_data, + ], 'pending', false); + + return ['status' => 'ok', 'record_id' => $effectiveRecordId]; + }); + } + + /** + * 查看某个实例的转交流水(按时间升序) + */ + public function listTransfers(int $instanceId): array + { + $rows = Db::table('approval_transfers') + ->where('instance_id', $instanceId) + ->orderBy('id', 'asc') + ->get() + ->toArray(); + + return array_map(function($r){ + return [ + 'id' => (int)$r->id, + 'instance_id' => (int)$r->instance_id, + 'step_index' => (int)$r->step_index, + 'record_id' => (int)$r->record_id, + 'from_user_id' => (int)$r->from_user_id, + 'to_user_id' => (int)$r->to_user_id, + 'reason' => $r->reason, + 'created_at' => $r->created_at, + ]; + }, $rows); + } + + + /** + * 转交当前待办给他人(原地替换 approver_id)并在通知里追加“来源/原因” + */ + public function transferApproval( + int $instanceId, + int $stepIndex, + int $fromUserId, + int $toUserId, + string $reason = '', + ?string $userNameColumn = null // 可选:传 'name' 或 'nickname' 来展示姓名 + ): array { + if ($fromUserId === $toUserId) { + return ['status' => 'noop', 'msg' => '目标与来源相同,无需转交']; + } + + return \support\Db::transaction(function () use ($instanceId, $stepIndex, $fromUserId, $toUserId, $reason, $userNameColumn) { + // 锁实例,避免并发推进 + $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); + if (!$instance) throw new BusinessException('审批实例未找到'); + if ($instance->status !== 'processing') throw new BusinessException('流程非进行中,无法转交'); + + // 锁定来源人的 pending 记录 + $record = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('round', (int)$instance->round) + ->where('step_index', $stepIndex) + ->where('approver_id', $fromUserId) + ->where('status', 'pending') + ->lockForUpdate() + ->first(); + if (!$record) throw new BusinessException('未找到可转交的待办或已处理'); + + $now = date('Y-m-d H:i:s'); + + // 若目标已有 pending:合并(本记录置为 skipped) + $target = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('round', (int)$instance->round) + ->where('step_index', $stepIndex) + ->where('approver_id', $toUserId) + ->where('status', 'pending') + ->lockForUpdate() + ->first(); + + if ($target) { + Db::table('approval_records')->where('id', $record->id)->update([ + 'status' => 'skipped', + 'approved_at' => $now, + 'updated_at' => $now, + 'comment' => trim(($record->comment ?? '')."\n[转交给 {$toUserId},目标已有待办,已合并]"), + ]); + $effectiveRecordId = (int)$target->id; + } else { + // 原地替换 approver_id + Db::table('approval_records')->where('id', $record->id)->update([ + 'approver_id' => $toUserId, + 'updated_at' => $now, + 'comment' => trim(($record->comment ?? '')."\n[自 {$fromUserId} 转交]"), + ]); + $effectiveRecordId = (int)$record->id; + } + + // 组织“转交来源/原因”文案(优先姓名) + $fromLabel = (string)$fromUserId; + $toLabel = (string)$toUserId; + if ($userNameColumn) { + $fromName = Db::table('user')->where('id', $fromUserId)->value($userNameColumn); + $toName = Db::table('user')->where('id', $toUserId)->value($userNameColumn); + if ($fromName) $fromLabel = $fromName; + if ($toName) $toLabel = $toName; + } + $note = "【转交】{$fromLabel} → {$toLabel}"; + $r = trim($reason); + if ($r !== '') $note .= ",原因:{$r}"; + + // 通知被转交人(在内容里追加 note) + $this->sendNotification([$toUserId], (object)[ + 'id' => (int)$instance->id, + 'flow_type' => (string)$instance->flow_type, + 'biz_type' => (string)$instance->biz_type, + 'biz_id' => (string)$instance->biz_id, + 'initiator_id' => (int)$instance->initiator_id, + 'form_data' => $instance->form_data, + ], 'pending', false, ['note' => $note]); + + // 记转交流水 + Db::table('approval_transfers')->insert([ + 'instance_id' => (int)$instanceId, + 'step_index' => (int)$stepIndex, + 'record_id' => $effectiveRecordId, + 'from_user_id' => (int)$fromUserId, + 'to_user_id' => (int)$toUserId, + 'reason' => $reason, + 'created_at' => $now, + ]); + + return ['status' => 'ok', 'record_id' => $effectiveRecordId]; + }); + } + +} \ 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/controller/IndexController.php b/app/controller/IndexController.php index a72264c..2d51caa 100644 --- a/app/controller/IndexController.php +++ b/app/controller/IndexController.php @@ -71,45 +71,48 @@ class IndexController */ public function createResource() { - // $options = [ - // 'controller' => [ - // 'module' => 'common', - // 'name' => 'purchase_order_items' - // ], - // 'model' => [ - // 'module' => 'common', - // 'name' => 'purchase_order_items' - // ], - // 'service' => [ - // 'module' => 'common', - // 'name' => 'purchase_order_items' - // ], - // 'logic' => [ - // 'module' => 'common', - // 'name' => 'purchase_order_items' - // ], - // 'validate' => [ - // 'module' => 'common', - // 'name' => 'purchase_order_items' - // ], - // 'table' => 'purchase_order_items' - // ]; + $options = [ + 'controller' => [ + 'module' => 'common', + 'name' => 'user_group_members' + ], + 'model' => [ + 'module' => 'common', + 'name' => 'user_group_members' + ], + 'service' => [ + 'module' => 'common', + 'name' => 'user_group_members' + ], + 'logic' => [ + 'module' => 'common', + 'name' => 'user_group_members' + ], + 'validate' => [ + 'module' => 'common', + 'name' => 'user_group_members' + ], + 'table' => 'user_group_members' + ]; - // $result = (new Generate($options))->doGenerate(); - // return $result; + $result = (new Generate($options))->doGenerate(); + return $result; } public function test(Request $request) { - $result = $this->tocurl('https://magicbridgeauto.com'); - if (preg_match('/magicbridgeauto/', $result['body'])) { - echo "未被篡改"; - flg("【magicbridgeauto】未被篡改"); - } else { - echo "已被篡改"; - flg("【magicbridgeauto】已被篡改"); - } + // $chain = \app\common\service\MenuBadgeService::buildBadgeChain(menuId: 226, leafCount: rand(1, 10)); + // (new \app\common\utils\WsEvents())->pushWebsocket('approval.todo.counts', 74, $chain); + + $data = [ + 'event' => 'approval.todo.counts', + 'user_id' => 74, + 'num' => rand(1, 10), + ]; + \Webman\RedisQueue\Redis::send('ws-msg', $data); + + return json(['code' => 0, 'msg' => 'success']); } diff --git a/app/crm/controller/StockOrdersController.php b/app/crm/controller/StockOrdersController.php index 77f8586..b297b51 100644 --- a/app/crm/controller/StockOrdersController.php +++ b/app/crm/controller/StockOrdersController.php @@ -38,7 +38,7 @@ class StockOrdersController extends BaseController if (isset($data['id'])) { $appro = new \app\common\utils\approval\service\ApprovalFlowService(); $appro = $appro->getBriefStatusByBiz($data['id'], null, 'name'); - $map = ['processing'=>'审批中','pending'=>'审批中','approved'=>'已通过','rejected'=>'已拒绝','revoked'=>'已撤回']; + $map = ['processing'=>'审批中','pending'=>'审批中','approved'=>'已通过','rejected'=>'已拒绝','revoked'=>'已撤回','voided'=>'已作废']; if ($appro) { $data['appro_status'] = [ 'status' => $appro['status'], @@ -182,6 +182,32 @@ class StockOrdersController extends BaseController } } + public function approvalNullify(Request $request) + { + try { + $raw = $this->service->serviceApprovalNullify($request); + $data = $this->transformData($raw, method_exists($this, 'approvalFormat') + ? [$this, 'approvalFormat'] + : null); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + public function approvalRevoke(Request $request) + { + try { + $raw = $this->service->serviceApprovalRevoke($request); + $data = $this->transformData($raw, method_exists($this, 'approvalFormat') + ? [$this, 'approvalFormat'] + : null); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + /** * 最终审批 */ diff --git a/app/crm/service/StockOrdersService.php b/app/crm/service/StockOrdersService.php index dd7d570..9a98e6f 100644 --- a/app/crm/service/StockOrdersService.php +++ b/app/crm/service/StockOrdersService.php @@ -72,11 +72,21 @@ class StockOrdersService extends \app\common\service\StockOrdersService { $config = $this->buildQueryConfig($request, 'detail'); $item = $this->logicShow($config); + + // 检查是否至少有一个车辆填写了vin + $hasVin = false; foreach ($item->items as $k => $v) { - if ($v->vin === '') { - throw new BusinessException('存在未填写vin的车辆,无法完成提交审核'); + if ($v->vin !== '') { + $hasVin = true; + break; } } + + // 如果没有任何车辆填写vin,则抛出异常 + if (!$hasVin) { + throw new BusinessException('至少需要为一辆车辆填写vin,才能提交审核'); + } + $this->logicUpdateBy($config, [ 'approval_status' => 1 ]); diff --git a/app/process/Task.php b/app/process/Task.php index 910b5ad..ccaa228 100644 --- a/app/process/Task.php +++ b/app/process/Task.php @@ -23,13 +23,20 @@ class Task ]; $customer = Customer::where($where)->get(); foreach ($customer as $item) { - $res =[ - 'id'=>$item->id, - 'category'=>'crm', - 'scene'=>'crm-person-follow-up', - 'receive type'=>'person' + $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("feishu-message",$res); + \Webman\RedisQueue\Redis::send("notice",$data); + $item->warn = 1; $item->save(); } diff --git a/app/queue/redis/InquiryToCrmUpdate.php b/app/queue/redis/InquiryToCrmUpdate_bak.php similarity index 98% rename from app/queue/redis/InquiryToCrmUpdate.php rename to app/queue/redis/InquiryToCrmUpdate_bak.php index 5a5a330..8dc3140 100644 --- a/app/queue/redis/InquiryToCrmUpdate.php +++ b/app/queue/redis/InquiryToCrmUpdate_bak.php @@ -11,7 +11,7 @@ use app\common\utils\FeishuMessage; use support\exception\BusinessException; use support\Log; -class InquiryToCrmUpdate implements Consumer +class InquiryToCrmUpdate_bak implements Consumer { // 要消费的队列名 public $queue = 'inquiry-to-crm-update'; diff --git a/app/queue/redis/InquiryToCrm.php b/app/queue/redis/InquiryToCrm_bak.php similarity index 98% rename from app/queue/redis/InquiryToCrm.php rename to app/queue/redis/InquiryToCrm_bak.php index ad973fd..c6f3b15 100644 --- a/app/queue/redis/InquiryToCrm.php +++ b/app/queue/redis/InquiryToCrm_bak.php @@ -11,7 +11,7 @@ use app\common\utils\FeishuMessage; use support\exception\BusinessException; use support\Log; -class InquiryToCrm implements Consumer +class InquiryToCrm_bak implements Consumer { // 要消费的队列名 public $queue = 'inquiry-to-crm'; 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/composer.json b/composer.json index 65fd08b..7ff8ace 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,8 @@ "phpoffice/phpspreadsheet": "^4.5", "phpmailer/phpmailer": "^6.10", "webman/gateway-worker": "^1.0", - "mpdf/mpdf": "^8.2" + "mpdf/mpdf": "^8.2", + "jaeger/querylist": "^4.4" }, "suggest": { "ext-event": "For better performance. " diff --git a/composer.lock b/composer.lock index feaeb5a..c3a23c9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4914e483b0c6fc599378c91d2eab17cf", + "content-hash": "865e38bae84e7de0c95098cc87135440", "packages": [ { "name": "aliyuncs/oss-sdk-php", @@ -1803,6 +1803,161 @@ }, "time": "2025-06-06T18:15:26+00:00" }, + { + "name": "jaeger/g-http", + "version": "V2.0.1", + "source": { + "type": "git", + "url": "https://github.com/jae-jae/GHttp.git", + "reference": "29cb537c57f230980ca10622de190f04831e35e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jae-jae/GHttp/zipball/29cb537c57f230980ca10622de190f04831e35e6", + "reference": "29cb537c57f230980ca10622de190f04831e35e6", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "php": ">=8.1", + "symfony/cache": "^6.4 || ^7.0" + }, + "require-dev": { + "predis/predis": "^2.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jaeger\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaeger", + "email": "JaegerCode@gmail.com" + } + ], + "description": "Simple Http client base on GuzzleHttp", + "support": { + "issues": "https://github.com/jae-jae/GHttp/issues", + "source": "https://github.com/jae-jae/GHttp/tree/V2.0.1" + }, + "time": "2024-06-14T08:13:32+00:00" + }, + { + "name": "jaeger/phpquery-single", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/jae-jae/phpQuery-single.git", + "reference": "39a650ade692a6b480c22220dce0c198d6a946fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jae-jae/phpQuery-single/zipball/39a650ade692a6b480c22220dce0c198d6a946fb", + "reference": "39a650ade692a6b480c22220dce0c198d6a946fb", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "phpQuery.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobiasz Cudnik", + "email": "tobiasz.cudnik@gmail.com", + "homepage": "https://github.com/TobiaszCudnik", + "role": "Developer" + }, + { + "name": "Jaeger", + "role": "Packager" + } + ], + "description": "phpQuery单文件版本,是Querylist的依赖(http://querylist.cc/),phpQuery项目主页:http://code.google.com/p/phpquery/", + "homepage": "http://code.google.com/p/phpquery/", + "support": { + "issues": "https://github.com/jae-jae/phpQuery-single/issues", + "source": "https://github.com/jae-jae/phpQuery-single/tree/1.1.1" + }, + "time": "2022-03-26T15:01:16+00:00" + }, + { + "name": "jaeger/querylist", + "version": "V4.4.6", + "source": { + "type": "git", + "url": "https://github.com/jae-jae/QueryList.git", + "reference": "939f4e41b94b4d3ef77071fb7f386abd2ceeb59a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jae-jae/QueryList/zipball/939f4e41b94b4d3ef77071fb7f386abd2ceeb59a", + "reference": "939f4e41b94b4d3ef77071fb7f386abd2ceeb59a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "jaeger/g-http": "^2.0", + "jaeger/phpquery-single": "^1", + "php": ">=8.1", + "symfony/var-dumper": ">3.4" + }, + "require-dev": { + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/Collect/Support/helpers.php", + "src/Collect/Support/alias.php" + ], + "psr-4": { + "QL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaeger", + "email": "JaegerCode@gmail.com" + } + ], + "description": "Simple, elegant, extensible PHP Web Scraper (crawler/spider),Use the css3 dom selector,Based on phpQuery! 简洁、优雅、可扩展的PHP采集工具(爬虫),基于phpQuery。", + "homepage": "http://querylist.cc", + "keywords": [ + "QueryList", + "phpQuery", + "spider" + ], + "support": { + "issues": "https://github.com/jae-jae/QueryList/issues", + "source": "https://github.com/jae-jae/QueryList/tree/V4.4.6" + }, + "funding": [ + { + "url": "https://opencollective.com/querylist", + "type": "open_collective" + } + ], + "time": "2024-12-13T06:16:14+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.4", diff --git a/config/plugin/webman/gateway-worker/process.php b/config/plugin/webman/gateway-worker/process.php index a429973..16180d9 100644 --- a/config/plugin/webman/gateway-worker/process.php +++ b/config/plugin/webman/gateway-worker/process.php @@ -8,7 +8,7 @@ return [ 'gateway' => [ 'handler' => Gateway::class, 'listen' => 'websocket://0.0.0.0:' . getenv('GATEWAY_LISTEN'), - 'count' => 2, + 'count' => 3, 'reloadable' => false, 'constructor' => ['config' => [ 'lanIp' => '127.0.0.1', @@ -22,7 +22,7 @@ return [ ], 'worker' => [ 'handler' => BusinessWorker::class, - 'count' => 6, + 'count' => 2, 'constructor' => ['config' => [ 'eventHandler' => plugin\webman\gateway\Events::class, 'name' => 'ChatBusinessWorker', diff --git a/config/route.php b/config/route.php index 4049182..687c002 100644 --- a/config/route.php +++ b/config/route.php @@ -75,6 +75,8 @@ Route::group('/common', function () { Route::any('/port', [app\common\controller\PresetController::class, 'port']); Route::any('/color', [app\common\controller\PresetController::class, 'color']); Route::any('/modification', [app\common\controller\PresetController::class, 'modification']); + Route::any('/usergroup', [app\common\controller\PresetController::class, 'userGroup']); + Route::any('/usergroup/scene', [app\common\controller\PresetController::class, 'userGroupScene']); }); @@ -87,6 +89,37 @@ Route::group('/common', function () { Route::any('/unread', [app\common\controller\MessageUserStatusController::class, 'listUnRead']); }); + + // 品牌 + Route::group('/brand', function () { + Route::any('/list', [app\common\controller\BrandController::class, 'index']); + Route::any('/show', [app\common\controller\BrandController::class, 'show']); + Route::any('/store', [app\common\controller\BrandController::class, 'store']); + Route::any('/update', [app\common\controller\BrandController::class, 'update']); + Route::any('/hot', [app\common\controller\BrandController::class, 'update']); + Route::any('/destroy', [app\common\controller\BrandController::class, 'destroy']); + Route::any('/crawlSeries', [app\common\controller\BrandController::class, 'crawlSeries']); + Route::any('/crawlSeriesCreate', [app\common\controller\BrandController::class, 'crawlSeriesCreate']); + // 车系 + Route::group('/series', function () { + Route::any('/list', [app\common\controller\BrandSeriesController::class, 'index']); + Route::any('/show', [app\common\controller\BrandSeriesController::class, 'show']); + Route::any('/store', [app\common\controller\BrandSeriesController::class, 'store']); + Route::any('/update', [app\common\controller\BrandSeriesController::class, 'update']); + Route::any('/destroy', [app\common\controller\BrandSeriesController::class, 'destroy']); + Route::any('/crawlModels', [app\common\controller\BrandSeriesController::class, 'crawlModels']); + Route::any('/crawlModelCreate', [app\common\controller\BrandSeriesController::class, 'crawlModelCreate']); + }); + }); + + // 港口 + Route::group('/port', function () { + Route::any('/list', [app\common\controller\SysPortController::class, 'index']); + Route::any('/show', [app\common\controller\SysPortController::class, 'show']); + Route::any('/store', [app\common\controller\SysPortController::class, 'store']); + Route::any('/update', [app\common\controller\SysPortController::class, 'update']); + Route::any('/destroy', [app\common\controller\SysPortController::class, 'destroy']); + }); }); // 后台管理 @@ -328,6 +361,7 @@ Route::group('/admin', function () { Route::any('/store', [app\admin\controller\StockOrdersController::class, 'store']); Route::any('/print', [app\admin\controller\StockOrdersController::class, 'print']); Route::any('/detail', [app\admin\controller\StockOrdersController::class, 'detail']); + Route::any('/destroy', [app\admin\controller\StockOrdersController::class, 'destroy']); }); // crm @@ -365,6 +399,15 @@ Route::group('/admin', function () { Route::any('/destroy', [app\common\controller\PurchaseOrdersController::class, 'destroy']); Route::any('/resubmit', [app\common\controller\PurchaseOrdersController::class, 'resubmit']); }); + + + Route::group('/user/group', function () { + Route::any('/list', [app\admin\controller\UserGroupController::class, 'index']); + Route::any('/show', [app\admin\controller\UserGroupController::class, 'show']); + Route::any('/store', [app\admin\controller\UserGroupController::class, 'store']); + Route::any('/update', [app\admin\controller\UserGroupController::class, 'update']); + Route::any('/destroy', [app\admin\controller\UserGroupController::class, 'destroy']); + }); }); @@ -429,7 +472,7 @@ Route::group('/crm', function () { Route::any('/user/edit', [app\common\controller\UserController::class, 'edit']); Route::any('/user/savePassword', [app\common\controller\UserController::class, 'savePassword']); - // 基础信息 + // 备货单 Route::group('/stock/order', function () { Route::any('/list', [app\crm\controller\StockOrdersController::class, 'index']); Route::any('/show', [app\crm\controller\StockOrdersController::class, 'show']); @@ -439,6 +482,9 @@ Route::group('/crm', function () { Route::any('/approval', [app\crm\controller\StockOrdersController::class, 'approval']); Route::any('/detail', [app\crm\controller\StockOrdersController::class, 'detail']); Route::any('/ultimate/approval', [app\crm\controller\StockOrdersController::class, 'ultimateApproval']); + Route::any('/nullify/approval', [app\crm\controller\StockOrdersController::class, 'approvalNullify']); + Route::any('/revoke/approval', [app\crm\controller\StockOrdersController::class, 'approvalRevoke']); + Route::any('/destroy', [app\crm\controller\StockOrdersController::class, 'destroy']); }); // 审批记录 @@ -448,6 +494,7 @@ Route::group('/crm', function () { Route::any('/action', [app\crm\controller\ApprovalRecordsController::class, 'approve']); Route::any('/todo', [app\admin\controller\ApprovalRecordsController::class, 'todo']); + Route::any('/border', [app\admin\controller\ApprovalRecordsController::class, 'businessOrder']); }); // 汽车颜色 @@ -468,6 +515,7 @@ Route::group('/crm', function () { Route::any('/store', [app\common\controller\VehVehicleSkuController::class, 'store']); Route::any('/update', [app\common\controller\VehVehicleSkuController::class, 'update']); Route::any('/destroy', [app\common\controller\VehVehicleSkuController::class, 'destroy']); + Route::any('/preset', [app\common\controller\VehVehicleSkuController::class, 'preset']); }); }); diff --git a/plugin/webman/gateway/Events.php b/plugin/webman/gateway/Events.php index d63708e..9c6573b 100644 --- a/plugin/webman/gateway/Events.php +++ b/plugin/webman/gateway/Events.php @@ -32,6 +32,7 @@ class Events public static function onClose($client_id) { + self::$wsEvents->logout($client_id); var_dump('关闭客户端ID:'.$client_id); } From 7ff7cba602f85790ee5283ff41e56e68767a6968 Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Sun, 12 Oct 2025 17:24:54 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=8E=BB=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/crmAdmin/service/CustomerService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/crmAdmin/service/CustomerService.php b/app/crmAdmin/service/CustomerService.php index 0ba9a31..6b5573a 100644 --- a/app/crmAdmin/service/CustomerService.php +++ b/app/crmAdmin/service/CustomerService.php @@ -250,7 +250,7 @@ class CustomerService $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, strip_tags($customerData['desc_collect'])); + $sheet->setCellValue('L' . $row, empty($customerData['desc_collect'])? '' :strip_tags($customerData['desc_collect'])); $sheet->setCellValue('M' . $row, $customerData['created_at']->toDateTimeString()); $row++; From cf05cce7a58d02359e6bce38db94eef2ae89ae5d Mon Sep 17 00:00:00 2001 From: mzl Date: Sun, 12 Oct 2025 17:36:15 +0800 Subject: [PATCH 5/7] ts --- app/common/utils/WsEvents.php | 2 +- app/controller/IndexController.php | 26 ++++++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app/common/utils/WsEvents.php b/app/common/utils/WsEvents.php index 64ecd4f..e539831 100644 --- a/app/common/utils/WsEvents.php +++ b/app/common/utils/WsEvents.php @@ -291,7 +291,7 @@ class WsEvents { $ly = [ 1 => 186, - 3 => 226, + 3 => 224, ]; if ($num === 0) { diff --git a/app/controller/IndexController.php b/app/controller/IndexController.php index 2d51caa..1d354c8 100644 --- a/app/controller/IndexController.php +++ b/app/controller/IndexController.php @@ -99,18 +99,32 @@ class IndexController 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 test(Request $request) { // $chain = \app\common\service\MenuBadgeService::buildBadgeChain(menuId: 226, leafCount: rand(1, 10)); // (new \app\common\utils\WsEvents())->pushWebsocket('approval.todo.counts', 74, $chain); - $data = [ - 'event' => 'approval.todo.counts', - 'user_id' => 74, - 'num' => rand(1, 10), - ]; - \Webman\RedisQueue\Redis::send('ws-msg', $data); + // $data = [ + // 'event' => 'approval.todo.counts', + // 'user_id' => 74, + // 'num' => rand(1, 10), + // ]; + // \Webman\RedisQueue\Redis::send('ws-msg', $data); return json(['code' => 0, 'msg' => 'success']); } From 48c41618c59b8bc9af2f8160868a12cdf17a0a5a Mon Sep 17 00:00:00 2001 From: mzl Date: Sun, 12 Oct 2025 18:34:10 +0800 Subject: [PATCH 6/7] Merge branch 'develop' --- app/common/service/StockOrdersService.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/common/service/StockOrdersService.php b/app/common/service/StockOrdersService.php index 6eab6dd..19371b0 100644 --- a/app/common/service/StockOrdersService.php +++ b/app/common/service/StockOrdersService.php @@ -15,6 +15,7 @@ use support\Db; use support\Model; use app\common\model\ApprovalInstances; use app\common\model\ApprovalRecords; +use support\exception\BusinessException; class StockOrdersService { @@ -112,6 +113,22 @@ class StockOrdersService $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; // } + + if ($data['is_final'] == 1) { + // 检查是否至少有一个车辆填写了vin + $hasVin = false; + foreach ($data['veh_list'] as $k => $v) { + if ($v['vin'] != '') { + $hasVin = true; + break; + } + } + // 如果没有任何车辆填写vin,则抛出异常 + if (!$hasVin) { + throw new BusinessException('至少需要为一辆车辆填写vin,才能提交审核'); + } + } + return $data; } From ad740bfcd65fd140609d053a457546c3d3a4b820 Mon Sep 17 00:00:00 2001 From: mzl Date: Sun, 12 Oct 2025 18:44:14 +0800 Subject: [PATCH 7/7] Merge branch 'develop' --- app/queue/redis/ApprovalCallback.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/queue/redis/ApprovalCallback.php b/app/queue/redis/ApprovalCallback.php index 58c0a1f..14a4670 100644 --- a/app/queue/redis/ApprovalCallback.php +++ b/app/queue/redis/ApprovalCallback.php @@ -20,6 +20,7 @@ class ApprovalCallback implements Consumer match ($data['flow_type']) { 'stock_orders' => $this->approvalStockOrders($data), 'purchase_orders' => $this->approvalPurchaseOrders($data), + 'stock_orders_nullify' => '', default => throw new BusinessException('未知的审批类型,无法执行回调操作') }; return true;