From b89777154109101e363aa62c5f48584dbd162a44 Mon Sep 17 00:00:00 2001 From: mzl Date: Wed, 24 Sep 2025 19:12:13 +0800 Subject: [PATCH 01/21] Merge branch 'develop' --- .../controller/ApprovalRecordsController.php | 62 +- .../PurchaseOrderItemsController.php | 33 + .../controller/PurchaseOrdersController.php | 137 ++ app/common/logic/PurchaseOrderItemsLogic.php | 17 + app/common/logic/PurchaseOrdersLogic.php | 51 + app/common/model/ApprovalInstances.php | 34 +- app/common/model/ApprovalRecords.php | 1 + app/common/model/PurchaseOrderItems.php | 71 + app/common/model/PurchaseOrders.php | 64 + app/common/service/ApprovalRecordsService.php | 69 +- .../service/PurchaseOrderItemsService.php | 31 + app/common/service/PurchaseOrdersService.php | 357 ++++ app/common/service/StockOrdersService.php | 264 ++- app/common/service/VehMoneyValueService.php | 46 + app/common/traits/crud/CrudServiceTrait.php | 11 +- app/common/utils/CrudUtil.php | 6 +- .../approval/service/ApprovalFlowService.php | 917 ++++++++-- .../ApprovalFlowService_2025092401.php | 1623 +++++++++++++++++ .../service/ApprovalFlowService_bak.php | 1135 ++++++++++++ app/common/utils/pdf/template/stockOrder.stub | 7 +- .../validate/PurchaseOrderItemsValidate.php | 29 + .../validate/PurchaseOrdersValidate.php | 29 + app/common/validate/StockOrdersValidate.php | 196 +- app/controller/IndexController.php | 61 +- app/crm/service/StockOrdersService.php | 678 +------ app/crm/service/StockOrdersService_bak2.php | 762 ++++++++ app/functions.php | 3 +- app/queue/redis/ApprovalCallback.php | 82 + app/queue/redis/Server.php | 2 + .../plugin/webman/gateway-worker/process.php | 2 +- config/route.php | 14 + 31 files changed, 5899 insertions(+), 895 deletions(-) create mode 100644 app/common/controller/PurchaseOrderItemsController.php create mode 100644 app/common/controller/PurchaseOrdersController.php create mode 100644 app/common/logic/PurchaseOrderItemsLogic.php create mode 100644 app/common/logic/PurchaseOrdersLogic.php create mode 100644 app/common/model/PurchaseOrderItems.php create mode 100644 app/common/model/PurchaseOrders.php create mode 100644 app/common/service/PurchaseOrderItemsService.php create mode 100644 app/common/service/PurchaseOrdersService.php create mode 100644 app/common/utils/approval/service/ApprovalFlowService_2025092401.php create mode 100644 app/common/utils/approval/service/ApprovalFlowService_bak.php create mode 100644 app/common/validate/PurchaseOrderItemsValidate.php create mode 100644 app/common/validate/PurchaseOrdersValidate.php create mode 100644 app/crm/service/StockOrdersService_bak2.php create mode 100644 app/queue/redis/ApprovalCallback.php diff --git a/app/admin/controller/ApprovalRecordsController.php b/app/admin/controller/ApprovalRecordsController.php index 2506845..aebbaa4 100644 --- a/app/admin/controller/ApprovalRecordsController.php +++ b/app/admin/controller/ApprovalRecordsController.php @@ -112,8 +112,9 @@ class ApprovalRecordsController extends BaseController 'instance_id' => $record['instance_id'], 'biz_id' => $record['instance']['biz_id'], - 'biz_desc' => '备货单号【'.$record['instance']['bizInfo']['order_no'].'】', + 'biz_desc' => '业务单号【'.$record['instance']['bizInfo']['order_no'].'】', 'biz_type' => $record['instance']['flow_type'], + 'flow_type' => $record['instance']['flow_type'], 'initiator_name' => $record['instance']['initiator']['name'], 'initiator_avatar' => $record['instance']['initiator']['avatar'], @@ -139,4 +140,63 @@ class ApprovalRecordsController extends BaseController } return $data; } + + + + public function businessOrder(Request $request) + { + try { + $raw = $this->service->serviceBusinessOrder($request, $request->all()); + $data = $this->transformData($raw, method_exists($this, 'borderFormat') + ? [$this, 'borderFormat'] + : null); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + + public function transferApproval(Request $request) + { + try { + $raw = $this->service->transferApproval($request, $request->all()); + $data = $this->transformData($raw, method_exists($this, 'transferFormat') + ? [$this, 'transferFormat'] + : null); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + + public function approvalTimeline(Request $request) + { + try { + $raw = $this->service->serviceApprovalTimeline($request, $request->all()); + $data = $this->transformData($raw, method_exists($this, 'timelineFormat') + ? [$this, 'timelineFormat'] + : null); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + + protected function timelineFormat($data) + { + $result = []; + foreach ($data as $record) { + $result[] = [ + 'id' => $record['id'], + 'name' => $record['approver']['name'], + 'comment' => $record['comment'] ?? '无', + 'time' => $record['updated_at'], + 'status_desc' => SysDictItem::where('item_value', $record['status'])->where('status', 1)->value('item_label'), + ]; + } + return $result; + } } \ No newline at end of file diff --git a/app/common/controller/PurchaseOrderItemsController.php b/app/common/controller/PurchaseOrderItemsController.php new file mode 100644 index 0000000..e602109 --- /dev/null +++ b/app/common/controller/PurchaseOrderItemsController.php @@ -0,0 +1,33 @@ +service = new PurchaseOrderItemsService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/PurchaseOrdersController.php b/app/common/controller/PurchaseOrdersController.php new file mode 100644 index 0000000..5b86f47 --- /dev/null +++ b/app/common/controller/PurchaseOrdersController.php @@ -0,0 +1,137 @@ +service = new PurchaseOrdersService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + protected function indexFormat($data) + { + if (isset($data['records']) && count($data['records']) > 0) { + foreach ($data['records'] as &$record) { + $contractFile = $record['contract_file'] ? explode(',', $record['contract_file']) : []; + $contractFileUrl = []; + if (count($contractFile) > 0) { + foreach ($contractFile as $file) { + $contractFileUrl[] = (new \app\common\service\OssService)->generatePrivateUrl($file); + } + } + $result = [ + 'id' => $record['id'], + 'order_no' => $record['order_no'], + 'pi_no' => $record['pi_no'], + 'buyer_id' => $record['buyer_id'], + 'buyer_name' => $record['buyer_name'], + 'contract_file' => $contractFile, + 'contract_file_url' => $contractFileUrl, + 'status' => $record['status'], + 'status_text' => array_flip(\app\common\service\SysDictService::pluckKeyValueByCode('purchase_status'))[$record['status']], + 'remark' => $record['remark'], + 'created_at' => date('Y-m-d H:i:s', $record['created_at']), + 'updated_at' => date('Y-m-d H:i:s', $record['updated_at']), + ]; + $record = $result; + } + unset($record); + } + return $data; + } + + /** + * 详情页数据格式化 + * + * @param array $data + * @return array + */ + protected function showFormat($data) + { + $contractFile = $data['contract_file'] ? explode(',', $data['contract_file']) : []; + $contractFileUrl = []; + if (count($contractFile) > 0) { + foreach ($contractFile as $file) { + $contractFileUrl[] = (new \app\common\service\OssService)->generatePrivateUrl($file); + } + } + $result = [ + 'id' => $data['id'], + 'order_no' => $data['order_no'], + 'pi_no' => $data['pi_no'], + 'buyer_id' => $data['buyer_id'], + 'buyer_name' => $data['buyer_name'], + 'contract_file' => $contractFile, + 'contract_file_url' => $contractFileUrl, + 'status' => $data['status'], + 'remark' => $data['remark'] + ]; + $temps = []; + if (isset($data['items']) && count($data['items']) > 0) { + foreach ($data['items'] as $record) { + if (!in_array($record['stockOrder']['id'], array_keys($temps))) { + $temps[$record['stockOrder']['id']] = [ + 'id' => $record['stockOrder']['id'], + 'order_no' => $record['stockOrder']['order_no'], + 'salesperson_snapshot' => $record['stockOrder']['salesperson_snapshot'], + 'biz_date' => date('Y-m-d', $record['stockOrder']['biz_date']) + ]; + } + $temps[$record['stockOrder']['id']]['stock_order_items'][] =[ + 'id' => $record['stockOrderItems']['id'], + 'model_snapshot' => $record['stockOrderItems']['model_snapshot'], + 'unique_id' => $record['stockOrderItems']['unique_id'], + ]; + } + } + $result['stock_order'] = array_values($temps); + return $result; + } + + /** + * 前置查询处理 + * + * @param Request $request + * @return array + */ + public function preQueryData(Request $request) + { + try { + $data = $this->service->preQueryData($request->all()); + return $this->success($data); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } + + /** + * 重提 + * + * @param Request $request + * @return mixed + */ + public function resubmit(Request $request) + { + try { + $raw = $this->service->serviceResubmit($request, $request->all()); + return $this->success($raw); + } catch (\Throwable $e) { + return $this->error($e->getMessage()); + } + } +} \ No newline at end of file diff --git a/app/common/logic/PurchaseOrderItemsLogic.php b/app/common/logic/PurchaseOrderItemsLogic.php new file mode 100644 index 0000000..6c5702d --- /dev/null +++ b/app/common/logic/PurchaseOrderItemsLogic.php @@ -0,0 +1,17 @@ +where('pi_no', '!=', '') + ->select('pi_no') + ->distinct() + ->get(); + } + + /** + * 根据PI号获取备货单 + */ + public function getOrderNoListByPiNo($piNo) + { + return (new StockOrders())->where('pi_no', $piNo) + ->select('order_no', 'biz_date', 'salesperson_snapshot', 'id as stock_orders_id', 'salesperson_id') + ->get(); + } + + /** + * 根据备货单号获取备货单明细 + */ + public function getOrderItemsListByOrderNo($stockOrdersId) + { + return (new StockOrderItems())->where('order_id', $stockOrdersId) + ->select('model_snapshot', 'color_interior_snapshot', 'color_exterior_snapshot', 'unique_id', 'id as stock_order_items_id') + ->get(); + } +} \ No newline at end of file diff --git a/app/common/model/ApprovalInstances.php b/app/common/model/ApprovalInstances.php index 48fe707..a7a2454 100644 --- a/app/common/model/ApprovalInstances.php +++ b/app/common/model/ApprovalInstances.php @@ -59,6 +59,7 @@ class ApprovalInstances extends Model 'current_step', 'finished_at', 'deadline_at', + 'round' ]; protected $casts = [ @@ -69,6 +70,27 @@ class ApprovalInstances extends Model 'updated_at' => 'datetime', ]; + + + // ====== ====== ====== 映射业务表 ====== ====== ====== + protected const BIZ_CLASS = [ + 'stock_orders' => \app\common\model\StockOrders::class, + 'purchase_orders' => \app\common\model\PurchaseOrders::class, + ]; + + public function getBizTypeAttribute($value) + { + return self::BIZ_CLASS[$value] ?? $value; + } + + public function bizInfo() + { + return $this->morphTo('bizInfo', 'biz_type', 'biz_id'); + } + // ====== ====== ====== 映射表业务 ====== ====== ====== + + + public function records() { return $this->hasMany(ApprovalRecords::class, 'instance_id', 'id'); @@ -84,18 +106,16 @@ class ApprovalInstances extends Model return $this->belongsTo(User::class, 'initiator_id', 'id'); } - public function bizInfo() - { - return $this->belongsTo(StockOrders::class, 'biz_id', 'id'); - } + // public function bizInfo2() + // { + // return $this->belongsTo(StockOrders::class, 'biz_id', 'id'); + // } /** * 获取当前步骤的所有审批记录(pending 状态) */ public function currentStepRecords() { - return $this->approvalRecords() - ->where('step_index', $this->current_step) - ->where('status', 'pending'); + return $this->approvalRecords()->where('step_index', $this->current_step)->where('status', 'pending'); } } \ No newline at end of file diff --git a/app/common/model/ApprovalRecords.php b/app/common/model/ApprovalRecords.php index 660f1a4..88a820e 100644 --- a/app/common/model/ApprovalRecords.php +++ b/app/common/model/ApprovalRecords.php @@ -57,6 +57,7 @@ class ApprovalRecords extends Model 'status', 'comment', 'approved_at', + 'round' ]; protected $casts = [ diff --git a/app/common/model/PurchaseOrderItems.php b/app/common/model/PurchaseOrderItems.php new file mode 100644 index 0000000..3648865 --- /dev/null +++ b/app/common/model/PurchaseOrderItems.php @@ -0,0 +1,71 @@ +belongsTo(PurchaseOrders::class, 'purchase_order_id', 'id'); + } + + + public function stockOrder() + { + return $this->belongsTo(StockOrders::class, 'stock_id', 'id'); + } + + public function stockOrderItems() + { + return $this->belongsTo(StockOrderItems::class, 'stock_items_id', 'id'); + } +} \ No newline at end of file diff --git a/app/common/model/PurchaseOrders.php b/app/common/model/PurchaseOrders.php new file mode 100644 index 0000000..f51349a --- /dev/null +++ b/app/common/model/PurchaseOrders.php @@ -0,0 +1,64 @@ +hasMany(PurchaseOrderItems::class, 'purchase_order_id', 'id'); + } + + public function buyer() + { + return $this->hasOne(User::class, 'id', 'buyer_id'); + } +} \ No newline at end of file diff --git a/app/common/service/ApprovalRecordsService.php b/app/common/service/ApprovalRecordsService.php index b2af9a7..b1bc4ba 100644 --- a/app/common/service/ApprovalRecordsService.php +++ b/app/common/service/ApprovalRecordsService.php @@ -57,7 +57,7 @@ class ApprovalRecordsService if ($p['scene'] === 'todo') { // if (!in_array(1, $request->user->role_ids)) { - $config['where']['approver_id'] = $request->user->uid; + // $config['where']['approver_id'] = $request->user->uid; // } $config['where']['status'] = 'pending'; $config['where']['instance.status'] = 'processing'; @@ -95,6 +95,17 @@ class ApprovalRecordsService $config['where']['instance_id'] = $p['instance_id']; $config['order']['id'] = 'desc'; break; + case 'border': + $config['where']['id'] = $p['id']; + $config['order']['id'] = 'desc'; + $config['with'] = [ + 'instance', + 'approver', + 'instance.flows', + 'instance.initiator', + 'instance.bizInfo' + ]; + break; } return $config; @@ -115,4 +126,60 @@ class ApprovalRecordsService $item = $this->logicListAll($config); return $this->postProcess('Logs', $item); } + + + public function serviceBusinessOrder(Request $request, array $data = []) + { + $config = $this->buildQueryConfig($request, 'border'); + $config = $this->borderConfig($config, $data['flow_type']); + $item = $this->logicShow($config); + $bizInfo = []; + $service = match ($data['flow_type']) { + 'stock_orders' => new \app\common\service\StockOrdersService(), + 'purchase_orders' => new \app\common\service\PurchaseOrdersService(), + default => new \app\common\service\StockOrdersService() + }; + $bizInfo = $service->formatData($item->instance->bizInfo); + return $this->postProcess('Border', $bizInfo); + } + + public function borderConfig($config, $type = 'stock_orders') + { + $with = match ($type) { + 'stock_orders' => [ + 'instance.bizInfo.items', + 'instance.bizInfo.items.options', + 'instance.bizInfo.items.moneyValues', + 'instance.bizInfo.accounts', + 'instance.bizInfo.moneyValues', + 'instance.bizInfo.salesperson', + ], + 'purchase_orders' => [ + 'instance.bizInfo.items', + 'instance.bizInfo.items.stockOrder', + 'instance.bizInfo.items.stockOrderItems' + ] + }; + $config['with'] = array_merge($config['with'], $with); + return $config; + } + + + public function transferApproval(Request $request, array $data = []) + { + $service = new \app\common\utils\approval\service\ApprovalFlowService(); + $result = $service->transferApproval($data['instance_id'], $data['step_index'], $request->user->uid, $data['to_uid'], $data['reason'], 'name'); + return $result; + } + + + public function serviceApprovalTimeline(Request $request, array $data = []) + { + $instance = \app\common\model\ApprovalInstances::where('biz_id', $data['id'])->where('flow_type', $data['flow_type'])->first(); + if (!$instance) { + return []; + } + $result = \app\common\model\ApprovalRecords::where('instance_id', $instance->id)->with('approver')->orderBy('id', 'desc')->get(); + return $result; + } } \ No newline at end of file diff --git a/app/common/service/PurchaseOrderItemsService.php b/app/common/service/PurchaseOrderItemsService.php new file mode 100644 index 0000000..3ead14c --- /dev/null +++ b/app/common/service/PurchaseOrderItemsService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/PurchaseOrdersService.php b/app/common/service/PurchaseOrdersService.php new file mode 100644 index 0000000..64ca27e --- /dev/null +++ b/app/common/service/PurchaseOrdersService.php @@ -0,0 +1,357 @@ +validator; + } + + + public function getLogic() + { + return new PurchaseOrdersLogic(); + } + + /** + * 获取前置预处理数据 + * + * @param array $data 原始返回值 + * @return array 处理后的返回值 + */ + public function preQueryData(array $data) + { + $result = match ($data['scene']) { + 'pi_no' => $this->getLogic()->getPiList(), + 'order_no' => $this->getLogic()->getOrderNoListByPiNo($data['pi_no']), + 'order_items' => $this->getLogic()->getOrderItemsListByOrderNo($data['order_id']), + default => $data, + }; + return $this->postProcess('PreData', $result->toArray()); + } + + public function afterServicePreData(array $data) + { + foreach ($data as $key => &$item) { + if (isset($item['biz_date'])) { + $item['biz_date'] = date('Y-m-d', $item['biz_date']); + } + } + unset($item); + return $data; + } + + public function beforeServiceStore(array $data, Request $request) + { + $data['order_no'] = generateStockNo('CG'); + $data['buyer_id'] = $request->user->uid; + $data['buyer_name'] = User::where('id', $request->user->uid)->value('name'); + if ($data['is_draft'] == 0) { + $data['status'] = 1; + } + return $data; + } + + public function serviceStore(Request $request, array $data = [], string $scene = 'store') + { + // 使用事务确保数据一致性 + return Db::transaction(function () use ($request, $data, $scene) { + $data = $this->preProcess(ucfirst($scene), $data, $request); + $dl = $this->getDataLimitFromRequest($request); + // 创建采购单 + $created = $this->logicCreate($data, $dl); + if (isset($data['stock_order'])) { + foreach ($data['stock_order'] as $order) { + foreach ($order['stock_items'] as $item) { + // 创建采购单明细 + PurchaseOrderItems::create([ + 'purchase_order_id' => $created->id, + 'stock_id' => $order['stock_orders_id'], + 'stock_items_id' => $item['stock_order_items_id'], + 'stock_items_uqid' => $item['unique_id'], + ]); + } + } + } + return $this->postProcess(ucfirst($scene), $created); + }); + } + + /** + * 构建查询配置 + * + * @param Request $request + * @param string $scene + * @return array + */ + protected function buildQueryConfig(Request $request, string $scene = 'index'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + + switch ($scene) { + case 'index': + $config['order']['id'] = 'desc'; + break; + case 'update': + $config['where']['id'] = $p['id'] ?? 0; + break; + case 'show': + $config['where']['id'] = $p['id']; + $config['with'] = [ + 'items', 'items.stockOrder', 'items.stockOrderItems' + ]; + break; + } + + return $config; + } + + /** + * 删除采购订单 + * + * @param Request $request + * @return mixed + */ + public function serviceDestroy(Request $request) + { + // 使用事务确保数据一致性 + return Db::transaction(function () use ($request) { + // 构建查询条件 + $config = $this->buildQueryConfig($request, 'show'); + + // 软删除采购单明细 + $order = $this->logicShow($config); + if ($order && $order->items) { + foreach ($order->items as $item) { + $item->delete(); + } + } + + // 软删除采购单主表 + $res = $order->delete(); + + return $this->postProcess('Destroy', $res); + }); + } + + /** + * 创建采购单成功后处理 + */ + protected function afterServiceStore($res) + { + if ($res->is_draft == 0) { + $this->startApproval($res, ['resubmitted'=>0]); + } + return $res; + } + + public function startApproval($item) + { + $appro = new \app\common\utils\approval\service\ApprovalFlowService(); + $appro->start('purchase_orders', 'purchase_orders', $item->id, ['resubmitted'=>0], $item->buyer_id); + } + + public function beforeServiceUpdate(array $data, Request $request) + { + if ($data['is_draft'] == 0) { + $data['status'] = 1; + } + return $data; + } + + /** + * 更新采购单 + * + * @param Request $request + * @param array $data + * @return mixed + */ + public function serviceUpdate(Request $request, array $data = [], string $scene = 'update') + { + // 使用事务确保数据一致性 + return Db::transaction(function () use ($request, $data, $scene) { + $data = $this->preProcess(ucfirst($scene), $data, $request); + + // 构建查询条件 + $config = $this->buildQueryConfig($request, $scene); + + // 更新主表信息 + $udata = $data; + unset($udata['stock_order']); + unset($udata['is_resubmit']); + unset($udata['approval_desc']); + $this->logicUpdateBy($config, $udata); + + // 获取更新后的数据 + $order = $this->logicShow($config); + + // 更新采购单明细 + if (isset($data['stock_order'])) { + // 先删除原有的明细 + PurchaseOrderItems::where('purchase_order_id', $order->id)->delete(); + + // 重新创建明细 + foreach ($data['stock_order'] as $orderData) { + foreach ($orderData['stock_items'] as $item) { + PurchaseOrderItems::create([ + 'purchase_order_id' => $order->id, + 'stock_id' => $orderData['stock_orders_id'], + 'stock_items_id' => $item['stock_order_items_id'], + 'stock_items_uqid' => $item['unique_id'], + ]); + } + } + } + + return $this->postProcess( + ucfirst($scene), + $order, + [ + 'is_resubmit'=>$data['is_resubmit'], + 'approval_desc'=>$data['approval_desc'] + ] + ); + }); + } + + protected function afterServiceUpdate($res, $data) + { + if ($data['is_resubmit'] == 0 && $res->status == 1) { + $this->startApproval($res); + } + if ($data['is_resubmit'] == 1) { + $instance = \app\common\model\ApprovalInstances::where('biz_id', $res->id)->where('flow_type', 'purchase_orders')->first(); + if ( $instance === null) { + throw new BusinessException('未找到审批实例'); + } + // 审批服务 + $appro = new \app\common\utils\approval\service\ApprovalFlowService(); + // 重新提交 + $appro->resubmit($instance->id, $instance->initiator_id, ['resubmitted'=>1], $data['approval_desc']); + // 添加到讨论 + if (!empty($data['approval_desc'])) { + $appro->postDiscussion($instance->id, $instance->initiator_id, $data['approval_desc'], false, [], 'name'); + } + } + return $res; + } + + + public function serviceResubmit(Request $request, array $data = []) + { + $p = $request->all(); + $config = []; + $config['model'] = $this->modelClass; + $config['where']['id'] = $p['id']; + $config['where']['buyer_id'] = $request->user->uid; + $info = $this->logicShow($config); + if ($info === null) { + throw new BusinessException('未找到采购单'); + } + if ($info->status != 3) { + throw new BusinessException('该状态不能重新提交'); + } + $instance = \app\common\model\ApprovalInstances::where('biz_id', $info->id)->where('flow_type', 'purchase_orders')->first(); + if ( $instance === null) { + throw new BusinessException('未找到审批实例'); + } + // 审批服务 + $appro = new \app\common\utils\approval\service\ApprovalFlowService(); + // 重新提交 + $result = $appro->resubmit($instance->id, $instance->initiator_id, ['resubmitted'=>1], $data['remark']); + // 添加到讨论 + if (!empty($data['remark'])) { + $appro->postDiscussion($instance->id, $instance->initiator_id, $data['remark'], false, [], 'name'); + } + + $info->status = 1; + $info->save(); + return $result; + } + + + public function updateStatus(array $data = []) + { + + $info = PurchaseOrders::where('id', $data['id'])->first(); + if ($info === null) { + throw new BusinessException('未找到采购单'); + } + $info->status = $data['status']; + return $info->save(); + } + + + public function formatData($data) + { + $contractFile = $data['contract_file'] ? explode(',', $data['contract_file']) : []; + $contractFileUrl = []; + if (count($contractFile) > 0) { + foreach ($contractFile as $file) { + $contractFileUrl[] = (new \app\common\service\OssService)->generatePrivateUrl($file); + } + } + $result = [ + 'id' => $data['id'], + 'order_no' => $data['order_no'], + 'pi_no' => $data['pi_no'], + 'buyer_id' => $data['buyer_id'], + 'buyer_name' => $data['buyer_name'], + 'contract_file' => $contractFile, + 'contract_file_url' => $contractFileUrl, + 'status' => $data['status'], + 'remark' => $data['remark'] + ]; + $temps = []; + if (isset($data['items']) && count($data['items']) > 0) { + foreach ($data['items'] as $record) { + if (!in_array($record['stockOrder']['id'], array_keys($temps))) { + $temps[$record['stockOrder']['id']] = [ + 'id' => $record['stockOrder']['id'], + 'order_no' => $record['stockOrder']['order_no'], + 'salesperson_snapshot' => $record['stockOrder']['salesperson_snapshot'], + 'biz_date' => date('Y-m-d', $record['stockOrder']['biz_date']) + ]; + } + $temps[$record['stockOrder']['id']]['stock_order_items'][] =[ + 'id' => $record['stockOrderItems']['id'], + 'model_snapshot' => $record['stockOrderItems']['model_snapshot'], + 'unique_id' => $record['stockOrderItems']['unique_id'], + ]; + } + } + $result['stock_order'] = array_values($temps); + return $result; + } +} \ No newline at end of file diff --git a/app/common/service/StockOrdersService.php b/app/common/service/StockOrdersService.php index 9308fde..ee34158 100644 --- a/app/common/service/StockOrdersService.php +++ b/app/common/service/StockOrdersService.php @@ -140,7 +140,7 @@ class StockOrdersService { // 启动审批 $appro = new \app\common\utils\approval\service\ApprovalFlowService(); - $appro->start('stock_orders', 'stock_orders', $item->id, $item->toArray(), $item->salesperson_id); + $appro->start('stock_orders', 'stock_orders', $item->id, [], $item->salesperson_id); // 异步生成pdf文件 \Webman\RedisQueue\Redis::send('stock-order-pdf', ['id'=>$item->id]); } @@ -160,7 +160,7 @@ class StockOrdersService $config['with'][] = 'moneyValues'; $config['with'][] = 'salesperson'; $item = $this->logicShow($config); - $pdfData = $this->pdfData($item); + $pdfData = $this->formatData($item); // 渲染模板为最终HTML $tplPath = app_path('common/utils/pdf/template/stockOrder.stub'); @@ -200,7 +200,7 @@ class StockOrdersService return $res; } - public function pdfData($item) + public function pdfData2($item) { $vehList = []; $index = 0; @@ -375,8 +375,8 @@ class StockOrdersService { $config = $this->buildQueryConfig($request, 'detail'); $item = $this->logicShow($config); - $pdfData = $this->pdfData($item); - return $this->postProcess('Detail', $pdfData); + $detail = $this->formatData($item); + return $this->postProcess('Detail', $detail); } /** @@ -620,7 +620,10 @@ class StockOrdersService 'fob_transport_fee' => 'FOB运输费', 'port_misc_fee' => '港杂', 'retrofit_fee' => '改装费', - 'ocean_freight' => '海运费' + 'ocean_freight' => '海运费', + 'commission' => '佣金', + 'purchase_tax' => '购置税', + 'tax_refund' => '预估退税' ]; foreach ($priceFields as $field => $description) { @@ -703,8 +706,12 @@ class StockOrdersService $portMiscFee = (float)($veh['port_misc_fee']['amount'] ?? 0); $retrofitFee = (float)($veh['retrofit_fee']['amount'] ?? 0); $oceanFreight = (float)($veh['ocean_freight']['amount'] ?? 0); + $commission = (float)($veh['commission']['amount'] ?? 0); + $purchaseTax = (float)($veh['purchase_tax']['amount'] ?? 0); + $taxRefund = (float)($veh['tax_refund']['amount'] ?? 0); - return $unitPrice - $ttPrice - $ddPrice - $handlingFee - $fobTransportFee - $portMiscFee - $retrofitFee - $oceanFreight; + // 退税额 是增加利润 + return $unitPrice - $ttPrice - $ddPrice - $handlingFee - $fobTransportFee - $portMiscFee - $retrofitFee - $oceanFreight - $commission - $purchaseTax + $taxRefund; } /** @@ -739,4 +746,247 @@ class StockOrdersService StockOrderAccounts::create($account); } } + + + // ====== ======= ====== ======= ====== 信息处理 ======= ====== ======= ====== ======= + + // PDF数据 + public function formatData($item) + { + // 提取车辆列表信息 + $vehListData = $this->prepareVehicleListData($item); + + // 提取订单金额信息 + $moneyValues = $this->prepareOrderMoneyValues($item); + + // 提取账户列表信息 + $accountList = $this->prepareAccountList($item); + + // 构建返回数据 + $veh = [ + // 基本信息 + 'order_no' => $item->order_no, + 'salesperson_snapshot' => $item->salesperson_snapshot, + 'sign_img_url' => $this->getSignatureImageUrl($item->salesperson->sign_img ?? null), + 'created_at' => date('Y-m-d', strtotime($item->created_at)), + + 'pi_no' => $item->pi_no, + 'biz_date' => date('Y-m-d', $item->biz_date), + 'clearance_country_snapshot' => $item->clearance_country_snapshot, + 'customer_source_snapshot' => $item->customer_source_snapshot, + 'customer_snapshot' => $item->customer_snapshot, + 'customer_country_snapshot' => $item->customer_country_snapshot, + + // 订单项 + 'veh_list' => array_values($vehListData['list']), + 'contract_exchange_rate' => $item->contract_exchange_rate, + 'cif_total_amount' => $moneyValues['cif_total_amount'] ?? [], + 'profit_total' => $moneyValues['profit_total'] ?? [], + + // 运输和交付信息 + 'declarant_name' => $item->declarant_name, + 'eta_ship_date' => date('Y-m-d', $item->eta_ship_date), + 'shipping_method_snapshot' => $item->shipping_method_snapshot, + 'domestic_port_snapshot' => $item->domestic_port_snapshot, + 'trade_term_snapshot' => $item->trade_term_snapshot, + 'clearance_port_snapshot' => $item->clearance_port_snapshot, + 'latest_arrival_date' => date('Y-m-d', $item->latest_arrival_date), + 'pick_up_date' => date('Y-m-d', $item->pick_up_date), + 'collect_all_date' => date('Y-m-d', $item->collect_all_date), + 'is_final' => $item->is_final, + + // 账户信息 + 'account_list' => $accountList, + 'acc_index' => count($accountList) + 1, + 'currency_symbol' => $item->settlement_currency, + 'sign_imgs' => $this->queryAppro($item->id), + 'contract_url' => $this->getContractUrl($item->contract ?? null) + ]; + + return $veh; + } + + /** + * 准备车辆列表数据 + */ + private function prepareVehicleListData($item): array + { + $vehList = []; + $index = 0; + $currencyInfo = $this->getCurrencyInfo($item->settlement_currency); + + foreach ($item->items as $v) { + // 如果已存在相同车辆,增加数量 + if (isset($vehList[$v->unique_id])) { + $this->incrementVehicleCount($vehList[$v->unique_id], $currencyInfo); + // 拼接 remark 值 + if (!empty($v->remark)) { + if (!empty($vehList[$v->unique_id]['remark'])) { + $vehList[$v->unique_id]['remark'] .= "; " . $v->remark; + } else { + $vehList[$v->unique_id]['remark'] = $v->remark; + } + } + continue; + } + + $index++; + // 处理金额信息 + $moneyValues = $this->processItemMoneyValues($v); + + // 处理改装信息 + $modificationText = $this->getModificationText($v->options); + + // 计算单项利润 + $itemProfit = $this->calculateProfit($moneyValues, $item); + + // 构建车辆信息 + $vehList[$v->unique_id] = [ + 'index' => $index, + 'model_snapshot' => $v->model_snapshot, + 'color_interior_snapshot' => $v->color_interior_snapshot, + 'color_exterior_snapshot' => $v->color_exterior_snapshot, + 'num' => 1, + 'modify' => $modificationText, + 'mileage' => $v->mileage, + 'remark' => $v->remark, + ] + $moneyValues; + + // 添加利润信息 + $vehList[$v->unique_id]['profit'] = [ + 'currency' => $item->settlement_currency, + 'amount' => $itemProfit, + 'currency_info' => $currencyInfo['currency_symbol'] . number_format($itemProfit, 2, '.', ',') + ]; + } + + return ['list' => $vehList]; + } + + /** + * 处理单项金额值 + */ + private function processItemMoneyValues($item): array + { + $moneyValues = []; + foreach ($item->moneyValues as $money) { + $moneyValues[$money->field] = [ + 'currency' => $money->currency, + 'amount' => $money->amount, + 'rate' => $money->exchange_rate, + 'currency_info' => $this->formatCurrency($money->amount, $money->currency) + ]; + } + return $moneyValues; + } + + /** + * 获取改装文本 + */ + private function getModificationText($options): string + { + $modificationText = ''; + foreach ($options as $option) { + $modificationText .= $option->option_name_snapshot . ' '; + } + return trim($modificationText); + } + + /** + * 增加车辆数量并更新相关金额显示 + */ + private function incrementVehicleCount(array &$vehicleData, array $currencyInfo): void + { + $vehicleData['num'] += 1; + + // 更新总价显示 + if (isset($vehicleData['total_price']['amount'])) { + $totalAmount = $vehicleData['total_price']['amount'] * $vehicleData['num']; + $vehicleData['total_price']['currency_info'] = $currencyInfo['currency_symbol'] . number_format($totalAmount, 2, '.', ','); + } + + // 更新利润显示 + if (isset($vehicleData['profit']['amount'])) { + $profitAmount = $vehicleData['profit']['amount'] * $vehicleData['num']; + $vehicleData['profit']['currency_info'] = $currencyInfo['currency_symbol'] . number_format($profitAmount, 2, '.', ','); + } + } + + /** + * 准备订单金额值 + */ + private function prepareOrderMoneyValues($item): array + { + $moneyValues = []; + foreach ($item->moneyValues as $money) { + $moneyValues[$money->field] = [ + 'currency' => $money->currency, + 'amount' => $money->amount, + 'rate' => $money->exchange_rate, + 'currency_info' => $this->formatCurrency($money->amount, $money->currency) + ]; + } + return $moneyValues; + } + + /** + * 准备账户列表 + */ + private function prepareAccountList($item): array + { + $accountList = []; + foreach ($item->accounts as $k => $account) { + $accountList[] = [ + 'index' => '收款' . ($k + 1), + 'remit_country_name_snapshot' => $account->remit_country_name_snapshot, + 'remit_date' => date('Y-m-d', $account->remit_date), + 'currency_name_snapshot' => $account->currency_name_snapshot, + 'account_name_snapshot' => $account->account_name_snapshot, + 'remitter_name' => $account->remitter_name, + 'amount' => $account->amount, + ]; + } + return $accountList; + } + + /** + * 获取货币信息 + */ + private function getCurrencyInfo(string $currencyCode): array + { + return SysCurrency::where('currency_code', $currencyCode) + ->first(['currency_symbol', 'currency_name']) + ->toArray(); + } + + /** + * 格式化货币显示 + */ + private function formatCurrency(float $amount, string $currencyCode): string + { + $currencySymbol = SysCurrency::where('currency_code', $currencyCode)->value('currency_symbol'); + return $currencySymbol . number_format($amount, 2, '.', ','); + } + + /** + * 获取签名图片URL + */ + private function getSignatureImageUrl(?string $signImg): string + { + if (empty($signImg)) { + return ''; + } + return (new \app\common\service\OssService)->generatePrivateUrl($signImg); + } + + /** + * 获取合同URL + */ + private function getContractUrl(?string $contract): string + { + if (empty($contract)) { + return ''; + } + return (new \app\common\service\OssService)->generatePrivateUrl($contract); + } } \ No newline at end of file diff --git a/app/common/service/VehMoneyValueService.php b/app/common/service/VehMoneyValueService.php index 361aa55..af45547 100644 --- a/app/common/service/VehMoneyValueService.php +++ b/app/common/service/VehMoneyValueService.php @@ -5,6 +5,7 @@ namespace app\common\service; use app\common\validate\VehMoneyValueValidate; use app\common\traits\crud\CrudServiceTrait; use app\common\model\VehMoneyValue; +use support\Db; class VehMoneyValueService { @@ -28,4 +29,49 @@ class VehMoneyValueService { return new $this->validator; } + + + /** + * 复制金额记录 + * + * @param string $fromScene 源场景 + * @param int $fromId 源对象ID + * @param string $toScene 目标场景 + * @param int $toId 目标对象ID + * @return int 插入条数 + * @throws BusinessException + */ + public function copyMoneyValues(string $fromScene, int $fromId, string $toScene, int $toId): int + { + // 1. 查询源数据 + $rows = Db::table('veh_money_value') + ->where('object_scene', $fromScene) + ->where('object_id', $fromId) + ->get() + ->toArray(); + + if (empty($rows)) { + return 0; + } + + $now = time(); + $insertData = []; + + foreach ($rows as $row) { + $insertData[] = [ + 'object_scene' => $toScene, + 'object_id' => $toId, + 'field' => $row->field, + 'currency' => $row->currency, + 'amount' => $row->amount, + 'exchange_rate' => $row->exchange_rate, + 'remark' => $row->remark, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + // 2. 插入新数据 + return Db::table('veh_money_value')->insert($insertData); + } } \ No newline at end of file diff --git a/app/common/traits/crud/CrudServiceTrait.php b/app/common/traits/crud/CrudServiceTrait.php index eb563fe..d956cad 100644 --- a/app/common/traits/crud/CrudServiceTrait.php +++ b/app/common/traits/crud/CrudServiceTrait.php @@ -175,9 +175,14 @@ trait CrudServiceTrait protected function postProcess(string $op, mixed $data): mixed { $hook = "afterService{$op}"; - return method_exists($this, $hook) - ? $this->{$hook}($data) - : $data; + if (method_exists($this, $hook)) { + $args = func_get_args(); + array_shift($args); + array_shift($args); + $params = array_merge([$data], $args); + return call_user_func_array([$this, $hook], $params); + } + return $data; } /** diff --git a/app/common/utils/CrudUtil.php b/app/common/utils/CrudUtil.php index 836e39e..90e7846 100644 --- a/app/common/utils/CrudUtil.php +++ b/app/common/utils/CrudUtil.php @@ -192,7 +192,11 @@ class CrudUtil */ public static function findOneBy(Builder $q): Model { - return $q->first(); + $model = $q->first(); + if (!$model) { + throw new BusinessException("记录不存在", 404); + } + return $model; } /** diff --git a/app/common/utils/approval/service/ApprovalFlowService.php b/app/common/utils/approval/service/ApprovalFlowService.php index ad6ba9c..6241057 100644 --- a/app/common/utils/approval/service/ApprovalFlowService.php +++ b/app/common/utils/approval/service/ApprovalFlowService.php @@ -35,6 +35,7 @@ class ApprovalFlowService 'initiator_id' => $initiatorId, 'status' => 'processing', 'current_step' => $firstIndex, + 'round' => 1, 'deadline_at' => $deadlineAt, 'created_at' => $now, 'updated_at' => $now, @@ -50,15 +51,22 @@ class ApprovalFlowService '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($instanceId, $firstIndex, $uid); + $this->insertApprovalRecord($instance, $firstIndex, $uid); } - $this->sendNotification($approverIds, $instance, 'pending', false); + $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]; @@ -76,8 +84,6 @@ class ApprovalFlowService $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first(); if (!$instance) throw new BusinessException("审批实例未找到"); if ($instance->status !== 'processing') { - // return ['status' => 'noop', 'reason' => 'instance_not_processing', 'instance_status' => $instance->status]; - // throw new BusinessException("实例未处理"); return ['status' => 'noop', 'msg' => '实例未处理']; } @@ -86,12 +92,14 @@ class ApprovalFlowService $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') @@ -102,21 +110,17 @@ class ApprovalFlowService // 已处理过? $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', 'reason' => 'already_handled', 'record_status' => $anyRecord->status]; - // throw new BusinessException("已处理"); return ['status' => 'noop', 'msg' => '已处理']; } // 步骤已推进 if ((int)$instance->current_step !== $idx) { - // return ['status' => 'noop', 'reason' => 'not_current_step', 'current_step' => (int)$instance->current_step]; - // throw new BusinessException("非当前步骤"); return ['status' => 'noop', 'msg' => '非当前审批']; } - // return ['status' => 'noop', 'reason' => 'no_pending_record']; return ['status' => 'noop', 'msg' => '无待处理记录']; } @@ -131,23 +135,27 @@ class ApprovalFlowService 'updated_at' => $now, ]); - // 异步生成pdf文件 9.11 add - if ($action === 'approve') { - \Webman\RedisQueue\Redis::send('stock-order-pdf', ['id'=>$instance->biz_id]); - } + // 异步审批回调 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, $idx, $type); // 'pending' | 'approve' | 'reject' + $outcome = $this->getStepOutcome($instanceId, $round, $idx, $type); // 'pending' | 'approve' | 'reject' if ($outcome === 'pending') { - // return ['status' => 'step_pending']; return ['status' => 'step_pending', 'msg' => '审批待定']; } // 3) 有结论:关闭本步其余 pending - $this->closeOtherPending($instanceId, $idx, [$approverId]); + $this->closeOtherPending($instanceId, $round, $idx, [$approverId]); // 4) 拒绝 => 结束 if ($outcome === 'reject') { @@ -157,8 +165,7 @@ class ApprovalFlowService 'updated_at' => $now, ]); $this->sendNotification([$instance->initiator_id], $instance, 'reject', false); - // return ['status' => 'completed', 'result' => 'reject']; - return ['status' => 'completed', 'msg' => '已拒绝']; + return ['status' => 'completed', 'msg' => '拒绝']; } // 5) 通过 => 进入下一步或结束 @@ -172,8 +179,7 @@ class ApprovalFlowService 'updated_at' => $now, ]); $this->sendNotification([$instance->initiator_id], $instance, 'approve', false); - // return ['status' => 'completed', 'result' => 'approve']; - return ['status' => 'completed', 'msg' => '已同意']; + return ['status' => 'completed', 'msg' => '同意']; } // 乐观推进 current_step @@ -183,7 +189,6 @@ class ApprovalFlowService ->where('status', 'processing') ->update(['current_step' => $nextIndex, 'updated_at' => $now]); if (!$affected) { - // return ['status' => 'noop', 'reason' => 'concurrent_progressed']; return ['status' => 'noop', 'msg' => '并发进展']; } @@ -191,12 +196,10 @@ class ApprovalFlowService $nextStep = $steps[$nextIndex]; $nextApprovers = $this->resolveApprovers($nextStep, $instance, true); foreach ($nextApprovers as $uid) { - $this->insertApprovalRecord($instanceId, $nextIndex, $uid); + $this->insertApprovalRecord($instance, $nextIndex, $uid); } $this->sendNotification($nextApprovers, $instance, 'pending', false); $this->notifyCCs($nextStep, $instance, 'pending'); - - // return ['status' => 'next_step', 'step_name' => $nextStep['name'] ?? ('Step '.$nextIndex)]; return ['status' => 'next_step', 'msg' => '进行下一步:'.$nextStep['name'] ?? ('Step '.$nextIndex)]; }); } @@ -245,6 +248,7 @@ class ApprovalFlowService $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'); @@ -317,6 +321,7 @@ class ApprovalFlowService 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]); @@ -474,19 +479,19 @@ class ApprovalFlowService * any:有 reject => reject;否则有 approve => approve;否则 pending * all:有 reject => reject;有 pending => pending;否则(全 approved 或 approved+skipped/timeout)=> approve */ - private function getStepOutcome(int $instanceId, int $stepIndex, string $type): string + 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++; } - // skipped/timeout 不影响聚合结论 + if ($r->status === 'pending') $pending++; + elseif ($r->status === 'approved') $approved++; + elseif ($r->status === 'rejected') $rejected++; } $type = strtolower($type); @@ -495,18 +500,18 @@ class ApprovalFlowService if ($approved > 0) return 'approve'; return 'pending'; } - // all if ($rejected > 0) return 'reject'; if ($pending > 0) return 'pending'; return $approved > 0 ? 'approve' : 'pending'; } /** 关闭本步其余待办为 skipped */ - private function closeOtherPending(int $instanceId, int $stepIndex, array $keepApproverIds = []): void + 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'); @@ -516,62 +521,95 @@ class ApprovalFlowService } /** 创建待办(去重) */ - private function insertApprovalRecord(int $instanceId, int $stepIndex, int $approverId): void + 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', $instanceId) + ->where('instance_id', (int)$instance->id) + ->where('round', $round) // ★ 限当前轮 ->where('step_index', $stepIndex) ->where('approver_id', $approverId) - ->count() > 0; - if (!$exists) { - Db::table('approval_records')->insert([ - 'instance_id' => $instanceId, - 'approver_id' => $approverId, - 'step_index' => $stepIndex, - 'action' => null, - 'status' => 'pending', - 'comment' => null, - 'approved_at' => null, - 'created_at' => $now, - 'updated_at' => $now, - ]); - } + ->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): void + /** 发送通知(支持附加文案:提交说明/申诉/讨论摘要) */ + private function sendNotification(array $userIds, object $instance, string $action, bool $isCC, array $extra = []): void { $map = [ - 'pending' => '待审批', - 'approve' => '已通过', - 'reject' => '已拒绝', - 'revoked' => '已撤回' + 'pending' => '待审批', + 'approve' => '已通过', + 'reject' => '已拒绝', + 'revoked' => '已撤回', ]; $prefix = $isCC ? '【抄送】' : '【审批】'; - $text = $map[$action] ?? $action; + $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); - $title = $prefix."备货单:{$stockOrder->order_no}"; - $flowType = $approvalType[$instance->flow_type]; - $content = "{$prefix}你有一条审批 {$text}:类型 {$flowType},业务单号 {$stockOrder->order_no}"; + 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': // 备货单作废 + 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 + 'email' => 1, ]); } + private function notifyInitiator(object $instance, string $result): void { $this->sendNotification([(int)$instance->initiator_id], $instance, $result, false); @@ -747,100 +785,6 @@ class ApprovalFlowService ]; } - /** - * 极简:审批实例的时间轴(只返回每一步的总状态) - * - * @param int $instanceId - * @return array - */ - public function getSimpleTimeline1(int $instanceId): 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) ?: []; - $currentStep = (int)$instance->current_step; - - // 读出所有记录并分组 - $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(); - - $byStep = []; - foreach ($records as $r) { - $idx = (int)$r->step_index; - $byStep[$idx][] = $r; - } - - $timeline = []; - - 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'; - - if ($hasRecs) { - // 统计是否全部 timeout(且无 approve/reject/pending) - $cnt = ['pending'=>0,'approved'=>0,'rejected'=>0,'timeout'=>0]; - foreach ($recs as $rr) { - if (isset($cnt[$rr->status])) $cnt[$rr->status]++; - } - - // 用已有的聚合规则拿到结论 - $outcome = $this->getStepOutcome($instanceId, $i, $type); // 'pending'|'approve'|'reject' - - if ($outcome === 'reject') { - $status = 'rejected'; - } elseif ($outcome === 'approve') { - $status = 'approved'; - } else { - // 没有结论,再看是不是纯超时 - if ($cnt['timeout'] > 0 && $cnt['approved'] === 0 && $cnt['rejected'] === 0 && $cnt['pending'] === 0) { - $status = 'timeout'; - } else { - $status = 'processing'; - } - } - } else { - // 无记录:根据位置与条件判断展示 - if ($i < $currentStep) { - $status = $condOk ? 'approved' : 'skipped'; - } elseif ($i === $currentStep && $instance->status === 'processing') { - // 理论上当前步应有记录;防御性展示为 processing - $status = 'processing'; - } else { - $status = $condOk ? 'pending' : 'skipped'; - } - } - - $timeline[] = [ - 'index' => (int)$i, - 'name' => $name, - 'type' => $type, // any | all - 'status' => $status, // approved | rejected | processing | pending | skipped | timeout - ]; - } - - return $timeline; - } - - /** * 极简时间轴(含每步时间 & 申请人信息) * @@ -864,7 +808,7 @@ class ApprovalFlowService * initiator_name?:?string * } */ - public function getSimpleTimeline(int $instanceId, ?string $userNameColumn = null, string $initiatorPlacement = 'prepend'): array + public function getSimpleTimeline2(int $instanceId, ?string $userNameColumn = null, string $initiatorPlacement = 'prepend'): array { // 基础数据 $instance = Db::table('approval_instances')->where('id', $instanceId)->first(); @@ -876,6 +820,7 @@ class ApprovalFlowService $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; @@ -887,6 +832,7 @@ class ApprovalFlowService // 审批记录 $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.*') @@ -959,7 +905,7 @@ class ApprovalFlowService $startedAt = $minCreated; // 总结果 - $outcome = $this->getStepOutcome($instanceId, $i, $type); // 'pending'|'approve'|'reject' + $outcome = $this->getStepOutcome($instanceId, $round, $i, $type); // 'pending'|'approve'|'reject' if ($outcome === 'reject') { $status = 'rejected'; $finishedAt = $minRejectAt; @@ -1012,6 +958,193 @@ class ApprovalFlowService } + 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获取“最新进展”的极简状态 * - 已完结:直接返回实例最终状态 @@ -1067,6 +1200,7 @@ class ApprovalFlowService } $steps = json_decode($flow->steps, true) ?: []; $idx = (int)$instance->current_step; + $round = (int)$instance->round; $meta = $steps[$idx] ?? []; $typeRaw = strtolower($meta['type'] ?? 'all'); @@ -1080,6 +1214,7 @@ class ApprovalFlowService $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")) @@ -1088,6 +1223,7 @@ class ApprovalFlowService } else { $pendingRows = Db::table('approval_records') ->where('instance_id', $instance->id) + ->where('round', $round) ->where('step_index', $idx) ->where('status', 'pending') ->select('approver_id') @@ -1108,6 +1244,7 @@ class ApprovalFlowService // 先快速统计是否纯 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") @@ -1131,5 +1268,471 @@ class ApprovalFlowService 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/utils/approval/service/ApprovalFlowService_2025092401.php b/app/common/utils/approval/service/ApprovalFlowService_2025092401.php new file mode 100644 index 0000000..40891c1 --- /dev/null +++ b/app/common/utils/approval/service/ApprovalFlowService_2025092401.php @@ -0,0 +1,1623 @@ +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, + '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, + ]; + + // 解析首步审批人(支持 role_id 或 roles.name) + $approverIds = $this->resolveApprovers($firstStep, $instance, true); + foreach ($approverIds as $uid) { + $this->insertApprovalRecord($instanceId, $firstIndex, $uid); + } + + // $this->sendNotification($approverIds, $instance, 'pending', false); + + $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; + $step = $steps[$idx] ?? null; + if (!$step) throw new BusinessException("当前步骤配置错误"); + + // 找到该用户在本步的 pending 记录(加锁) + $pendingRecord = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->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('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, + ]); + + // // 异步生成pdf文件 9.11 add + // if ($action === 'approve') { + // \Webman\RedisQueue\Redis::send('stock-order-pdf', ['id'=>$instance->biz_id]); + // } + // 异步审批回调 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, $idx, $type); // 'pending' | 'approve' | 'reject' + + if ($outcome === 'pending') { + return ['status' => 'step_pending', 'msg' => '审批待定']; + } + + // 3) 有结论:关闭本步其余 pending + $this->closeOtherPending($instanceId, $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($instanceId, $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) + ->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('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 $stepIndex, string $type): string + { + $records = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->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++; } + // skipped/timeout 不影响聚合结论 + } + + $type = strtolower($type); + if ($type === 'any') { + if ($rejected > 0) return 'reject'; + if ($approved > 0) return 'approve'; + return 'pending'; + } + // all + if ($rejected > 0) return 'reject'; + if ($pending > 0) return 'pending'; + return $approved > 0 ? 'approve' : 'pending'; + } + + /** 关闭本步其余待办为 skipped */ + private function closeOtherPending(int $instanceId, int $stepIndex, array $keepApproverIds = []): void + { + $now = date('Y-m-d H:i:s'); + $q = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->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(int $instanceId, int $stepIndex, int $approverId): void + { + $now = date('Y-m-d H:i:s'); + $exists = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('step_index', $stepIndex) + ->where('approver_id', $approverId) + ->count() > 0; + if (!$exists) { + Db::table('approval_records')->insert([ + 'instance_id' => $instanceId, + 'approver_id' => $approverId, + 'step_index' => $stepIndex, + '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 + * @return array + */ + public function getSimpleTimeline1(int $instanceId): 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) ?: []; + $currentStep = (int)$instance->current_step; + + // 读出所有记录并分组 + $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(); + + $byStep = []; + foreach ($records as $r) { + $idx = (int)$r->step_index; + $byStep[$idx][] = $r; + } + + $timeline = []; + + 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'; + + if ($hasRecs) { + // 统计是否全部 timeout(且无 approve/reject/pending) + $cnt = ['pending'=>0,'approved'=>0,'rejected'=>0,'timeout'=>0]; + foreach ($recs as $rr) { + if (isset($cnt[$rr->status])) $cnt[$rr->status]++; + } + + // 用已有的聚合规则拿到结论 + $outcome = $this->getStepOutcome($instanceId, $i, $type); // 'pending'|'approve'|'reject' + + if ($outcome === 'reject') { + $status = 'rejected'; + } elseif ($outcome === 'approve') { + $status = 'approved'; + } else { + // 没有结论,再看是不是纯超时 + if ($cnt['timeout'] > 0 && $cnt['approved'] === 0 && $cnt['rejected'] === 0 && $cnt['pending'] === 0) { + $status = 'timeout'; + } else { + $status = 'processing'; + } + } + } else { + // 无记录:根据位置与条件判断展示 + if ($i < $currentStep) { + $status = $condOk ? 'approved' : 'skipped'; + } elseif ($i === $currentStep && $instance->status === 'processing') { + // 理论上当前步应有记录;防御性展示为 processing + $status = 'processing'; + } else { + $status = $condOk ? 'pending' : 'skipped'; + } + } + + $timeline[] = [ + 'index' => (int)$i, + 'name' => $name, + 'type' => $type, // any | all + 'status' => $status, // approved | rejected | processing | pending | skipped | timeout + ]; + } + + return $timeline; + } + + + /** + * 极简时间轴(含每步时间 & 申请人信息) + * + * @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 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; + + // 申请人 + $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) + ->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, $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; + } + + + /** + * 按业务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; + $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.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('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('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]); + + // 重新开启实例 + Db::table('approval_instances')->where('id', $instanceId)->update([ + 'status' => 'processing', + 'current_step' => $firstIndex, + '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, + ]; + $approvers = $this->resolveApprovers($firstStep, $instanceLite, true); + if (empty($approvers)) throw new BusinessException('重提后首步未解析到审批人'); + + foreach ($approvers as $uid) { + $this->insertApprovalRecord($instanceId, $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('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('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('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('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/utils/approval/service/ApprovalFlowService_bak.php b/app/common/utils/approval/service/ApprovalFlowService_bak.php new file mode 100644 index 0000000..deea2d1 --- /dev/null +++ b/app/common/utils/approval/service/ApprovalFlowService_bak.php @@ -0,0 +1,1135 @@ +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, + '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, + ]; + + // 解析首步审批人(支持 role_id 或 roles.name) + $approverIds = $this->resolveApprovers($firstStep, $instance, true); + foreach ($approverIds as $uid) { + $this->insertApprovalRecord($instanceId, $firstIndex, $uid); + } + + $this->sendNotification($approverIds, $instance, 'pending', false); + $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', 'reason' => 'instance_not_processing', 'instance_status' => $instance->status]; + // throw new BusinessException("实例未处理"); + 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; + $step = $steps[$idx] ?? null; + if (!$step) throw new BusinessException("当前步骤配置错误"); + + // 找到该用户在本步的 pending 记录(加锁) + $pendingRecord = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->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('approver_id', $approverId) + ->where('step_index', $idx) + ->first(); + if ($anyRecord && $anyRecord->status !== 'pending') { + // return ['status' => 'noop', 'reason' => 'already_handled', 'record_status' => $anyRecord->status]; + // throw new BusinessException("已处理"); + return ['status' => 'noop', 'msg' => '已处理']; + } + // 步骤已推进 + if ((int)$instance->current_step !== $idx) { + // return ['status' => 'noop', 'reason' => 'not_current_step', 'current_step' => (int)$instance->current_step]; + // throw new BusinessException("非当前步骤"); + return ['status' => 'noop', 'msg' => '非当前审批']; + } + // return ['status' => 'noop', 'reason' => 'no_pending_record']; + 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, + ]); + + // 异步生成pdf文件 9.11 add + if ($action === 'approve') { + \Webman\RedisQueue\Redis::send('stock-order-pdf', ['id'=>$instance->biz_id]); + } + + // 2) 聚合本步结论(any / all) + $typeRaw = strtolower($step['type'] ?? 'all'); + $type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all'; + $outcome = $this->getStepOutcome($instanceId, $idx, $type); // 'pending' | 'approve' | 'reject' + + if ($outcome === 'pending') { + // return ['status' => 'step_pending']; + return ['status' => 'step_pending', 'msg' => '审批待定']; + } + + // 3) 有结论:关闭本步其余 pending + $this->closeOtherPending($instanceId, $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', 'result' => 'reject']; + 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', 'result' => 'approve']; + 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', 'reason' => 'concurrent_progressed']; + return ['status' => 'noop', 'msg' => '并发进展']; + } + + // 生成下一步待办 + 通知 + $nextStep = $steps[$nextIndex]; + $nextApprovers = $this->resolveApprovers($nextStep, $instance, true); + foreach ($nextApprovers as $uid) { + $this->insertApprovalRecord($instanceId, $nextIndex, $uid); + } + $this->sendNotification($nextApprovers, $instance, 'pending', false); + $this->notifyCCs($nextStep, $instance, 'pending'); + + // return ['status' => 'next_step', 'step_name' => $nextStep['name'] ?? ('Step '.$nextIndex)]; + 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) + ->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('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 $stepIndex, string $type): string + { + $records = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->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++; } + // skipped/timeout 不影响聚合结论 + } + + $type = strtolower($type); + if ($type === 'any') { + if ($rejected > 0) return 'reject'; + if ($approved > 0) return 'approve'; + return 'pending'; + } + // all + if ($rejected > 0) return 'reject'; + if ($pending > 0) return 'pending'; + return $approved > 0 ? 'approve' : 'pending'; + } + + /** 关闭本步其余待办为 skipped */ + private function closeOtherPending(int $instanceId, int $stepIndex, array $keepApproverIds = []): void + { + $now = date('Y-m-d H:i:s'); + $q = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->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(int $instanceId, int $stepIndex, int $approverId): void + { + $now = date('Y-m-d H:i:s'); + $exists = Db::table('approval_records') + ->where('instance_id', $instanceId) + ->where('step_index', $stepIndex) + ->where('approver_id', $approverId) + ->count() > 0; + if (!$exists) { + Db::table('approval_records')->insert([ + 'instance_id' => $instanceId, + 'approver_id' => $approverId, + 'step_index' => $stepIndex, + '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): 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); + $title = $prefix."备货单:{$stockOrder->order_no}"; + $flowType = $approvalType[$instance->flow_type]; + $content = "{$prefix}你有一条审批 {$text}:类型 {$flowType},业务单号 {$stockOrder->order_no}"; + break; + case 'stock_orders_nullify': // 备货单作废 + break; + } + + 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 + * @return array + */ + public function getSimpleTimeline1(int $instanceId): 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) ?: []; + $currentStep = (int)$instance->current_step; + + // 读出所有记录并分组 + $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(); + + $byStep = []; + foreach ($records as $r) { + $idx = (int)$r->step_index; + $byStep[$idx][] = $r; + } + + $timeline = []; + + 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'; + + if ($hasRecs) { + // 统计是否全部 timeout(且无 approve/reject/pending) + $cnt = ['pending'=>0,'approved'=>0,'rejected'=>0,'timeout'=>0]; + foreach ($recs as $rr) { + if (isset($cnt[$rr->status])) $cnt[$rr->status]++; + } + + // 用已有的聚合规则拿到结论 + $outcome = $this->getStepOutcome($instanceId, $i, $type); // 'pending'|'approve'|'reject' + + if ($outcome === 'reject') { + $status = 'rejected'; + } elseif ($outcome === 'approve') { + $status = 'approved'; + } else { + // 没有结论,再看是不是纯超时 + if ($cnt['timeout'] > 0 && $cnt['approved'] === 0 && $cnt['rejected'] === 0 && $cnt['pending'] === 0) { + $status = 'timeout'; + } else { + $status = 'processing'; + } + } + } else { + // 无记录:根据位置与条件判断展示 + if ($i < $currentStep) { + $status = $condOk ? 'approved' : 'skipped'; + } elseif ($i === $currentStep && $instance->status === 'processing') { + // 理论上当前步应有记录;防御性展示为 processing + $status = 'processing'; + } else { + $status = $condOk ? 'pending' : 'skipped'; + } + } + + $timeline[] = [ + 'index' => (int)$i, + 'name' => $name, + 'type' => $type, // any | all + 'status' => $status, // approved | rejected | processing | pending | skipped | timeout + ]; + } + + return $timeline; + } + + + /** + * 极简时间轴(含每步时间 & 申请人信息) + * + * @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 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; + + // 申请人 + $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) + ->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, $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; + } + + + /** + * 按业务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; + $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.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('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('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; + } + + +} \ No newline at end of file diff --git a/app/common/utils/pdf/template/stockOrder.stub b/app/common/utils/pdf/template/stockOrder.stub index a11bfa1..e559157 100644 --- a/app/common/utils/pdf/template/stockOrder.stub +++ b/app/common/utils/pdf/template/stockOrder.stub @@ -110,7 +110,7 @@ 阿里订单号/PI号: 日期: - 关目的国: + 清关目的国: 客户来源: 客户名称: 客户所在国: @@ -174,6 +174,11 @@ + + + 备注: + + diff --git a/app/common/validate/PurchaseOrderItemsValidate.php b/app/common/validate/PurchaseOrderItemsValidate.php new file mode 100644 index 0000000..e44a977 --- /dev/null +++ b/app/common/validate/PurchaseOrderItemsValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/PurchaseOrdersValidate.php b/app/common/validate/PurchaseOrdersValidate.php new file mode 100644 index 0000000..a8b4a5a --- /dev/null +++ b/app/common/validate/PurchaseOrdersValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/StockOrdersValidate.php b/app/common/validate/StockOrdersValidate.php index 78fccf3..884f3ae 100644 --- a/app/common/validate/StockOrdersValidate.php +++ b/app/common/validate/StockOrdersValidate.php @@ -6,24 +6,204 @@ use think\Validate; class StockOrdersValidate extends Validate { - // 规则 + /** + * 验证规则 + */ protected $rule = [ - + 'id' => 'number|egt:0', + 'order_no' => 'require|max:40|unique:stock_orders', + 'pi_no' => 'require|max:64', + 'biz_date' => 'require', + 'salesperson_id' => 'number|egt:0', + 'salesperson_snapshot' => 'max:64', + 'clearance_country_id' => 'require|number|egt:0', + 'clearance_country_snapshot' => 'require|max:64', + 'customer_source_id' => 'require|number|egt:0', + 'customer_source_snapshot' => 'require|max:64', + 'customer_id' => 'require|number|egt:0', + 'customer_snapshot' => 'require|max:128', + 'customer_country_id' => 'require|number|egt:0', + 'customer_country_snapshot' => 'require|max:64', + 'declarant_name' => 'require|max:64', + 'eta_ship_date' => 'number|egt:0', + 'shipping_method_id' => 'require|number', + 'shipping_method_snapshot' => 'require|max:32', + 'cif_total_amount' => 'require|float|egt:0', + 'domestic_port_id' => 'require|number|egt:0', + 'domestic_port_snapshot' => 'require|max:64', + 'trade_term_id' => 'require|number|egt:0', + 'trade_term_snapshot' => 'require|max:64', + 'clearance_port_id' => 'require|number|egt:0', + 'clearance_port_snapshot' => 'require|max:64', + 'latest_arrival_date' => 'number|egt:0', + 'contract_exchange_rate' => 'require|float|egt:0', + 'settlement_currency' => 'require|length:3|alpha', + 'remark' => 'max:255', + 'contract' => 'max:255', + 'is_use' => 'in:0,1', + 'object_id' => 'number|egt:0', + 'is_final' => 'in:0,1', + 'approval_status' => 'in:0,1,2,3', + 'status' => 'in:0,1', + 'file_url' => 'max:255', + 'pick_up_date' => 'number|egt:0', + 'collect_all_date' => 'number|egt:0', + 'created_at' => 'number|egt:0', + 'updated_at' => 'number|egt:0', + 'deleted_at' => 'number|egt:0', ]; - // 提示 + /** + * 错误提示信息 + */ protected $message = [ - + 'order_no.require' => '备货单编号不能为空', + 'order_no.max' => '备货单编号长度不能超过40个字符', + 'order_no.unique' => '备货单编号已存在', + 'pi_no.require' => 'PI号不能为空', + 'pi_no.max' => 'PI号长度不能超过64个字符', + 'biz_date.require' => '单据日期不能为空', + 'clearance_country_id.require' => '清关目的国ID不能为空', + 'clearance_country_snapshot.require' => '清关目的国名称不能为空', + 'customer_source_id.require' => '客户来源ID不能为空', + 'customer_source_snapshot.require' => '客户来源名称不能为空', + 'customer_id.require' => '客户ID不能为空', + 'customer_snapshot.require' => '客户名称不能为空', + 'customer_country_id.require' => '客户所在国ID不能为空', + 'customer_country_snapshot.require' => '客户所在国名称不能为空', + 'declarant_name.require' => '清关人不能为空', + 'shipping_method_id.require' => '出运方式ID不能为空', + 'shipping_method_snapshot.require' => '出运方式不能为空', + 'cif_total_amount.require' => '合计到岸价不能为空', + 'cif_total_amount.float' => '合计到岸价必须为数字', + 'cif_total_amount.egt' => '合计到岸价不能为负数', + 'domestic_port_id.require' => '国内收货港ID不能为空', + 'domestic_port_snapshot.require' => '国内收货港名称不能为空', + 'trade_term_id.require' => '贸易条款ID不能为空', + 'trade_term_snapshot.require' => '贸易条款名称不能为空', + 'clearance_port_id.require' => '清关目的港ID不能为空', + 'clearance_port_snapshot.require' => '清关目的港名称不能为空', + 'contract_exchange_rate.require' => '合同汇率不能为空', + 'contract_exchange_rate.float' => '合同汇率必须为数字', + 'contract_exchange_rate.egt' => '合同汇率不能为负数', + 'settlement_currency.require' => '结算币种不能为空', + 'settlement_currency.length' => '结算币种必须为3个字符', + 'settlement_currency.alpha' => '结算币种必须为字母', + 'approval_status.in' => '审批状态值不正确', + 'status.in' => '状态值不正确', + 'is_final.in' => '是否终版值不正确', + 'is_use.in' => '是否开过终版值不正确', ]; - // 场景 + /** + * 验证场景 + */ protected $scene = [ + // 创建场景 'store' => [ - + 'pi_no', 'biz_date', + 'clearance_country_id', 'clearance_country_snapshot', 'customer_source_id', + 'customer_source_snapshot', 'customer_id', 'customer_snapshot', + 'customer_country_id', 'customer_country_snapshot', 'declarant_name', + 'shipping_method_id', 'shipping_method_snapshot', + 'domestic_port_id', 'domestic_port_snapshot', + 'trade_term_id', 'trade_term_snapshot', 'clearance_port_id', + 'clearance_port_snapshot', 'contract_exchange_rate', + 'settlement_currency' ], - + + // 更新场景 'update' => [ - + 'pi_no', 'biz_date', + 'clearance_country_id', 'clearance_country_snapshot', 'customer_source_id', + 'customer_source_snapshot', 'customer_id', 'customer_snapshot', + 'customer_country_id', 'customer_country_snapshot', 'declarant_name', + 'shipping_method_id', 'shipping_method_snapshot', + 'domestic_port_id', 'domestic_port_snapshot', + 'trade_term_id', 'trade_term_snapshot', 'clearance_port_id', + 'clearance_port_snapshot', 'contract_exchange_rate', + 'settlement_currency' + ], + + // 提交审批场景 + 'submit' => [ + 'id' => 'require|number|egt:1', + 'status' => 'require|in:1' + ], + + // 审批场景 + 'approve' => [ + 'id' => 'require|number|egt:1', + 'approval_status' => 'require|in:1,2,3' + ], + + // 状态更新场景 + 'status' => [ + 'id' => 'require|number|egt:1', + 'status' => 'require|in:0,1' + ], + + // 简略验证场景(用于快速验证) + 'simple' => [ + 'order_no', 'biz_date', 'cif_total_amount', 'settlement_currency' ] ]; + + /** + * 自定义验证规则 - 验证时间戳是否合理 + */ + protected function checkTimestamp($value, $rule, $data = []) + { + if ($value === 0 || $value === '0') { + return true; + } + + // 时间戳应该在1970年到现在+1年的范围内 + $minTimestamp = 0; + $maxTimestamp = time() + (365 * 24 * 60 * 60); + + return $value >= $minTimestamp && $value <= $maxTimestamp; + } + + /** + * 自定义验证规则 - 验证金额格式 + */ + protected function checkMoney($value, $rule, $data = []) + { + if (!is_numeric($value)) { + return false; + } + + // 验证小数点位数不超过2位 + if (strpos($value, '.') !== false) { + $decimalPart = explode('.', $value)[1]; + if (strlen($decimalPart) > 2) { + return false; + } + } + + return true; + } + + /** + * 场景验证前回调 - 添加自定义规则 + */ + // protected function sceneBefore($scene) + // { + // // 为时间戳字段添加自定义验证 + // $timestampFields = ['biz_date', 'eta_ship_date', 'latest_arrival_date', 'pick_up_date', 'collect_all_date', 'created_at', 'updated_at', 'deleted_at']; + // foreach ($timestampFields as $field) { + // if (isset($this->scene[$scene][$field])) { + // $this->rule[$field] .= '|checkTimestamp'; + // } + // } + + // // 为金额字段添加自定义验证 + // $moneyFields = ['cif_total_amount', 'contract_exchange_rate']; + // foreach ($moneyFields as $field) { + // if (isset($this->scene[$scene][$field])) { + // $this->rule[$field] .= '|checkMoney'; + // } + // } + // } } \ No newline at end of file diff --git a/app/controller/IndexController.php b/app/controller/IndexController.php index b25b5a4..fb87763 100644 --- a/app/controller/IndexController.php +++ b/app/controller/IndexController.php @@ -9,6 +9,7 @@ use app\common\utils\scaffold\Generate; use support\Request; use support\Db; use support\Cache; +use support\Redis; class IndexController { @@ -69,42 +70,42 @@ class IndexController */ public function createResource() { - $options = [ - 'controller' => [ - 'module' => 'crm', - 'name' => 'approval_flows' - ], - 'model' => [ - 'module' => 'crm', - 'name' => 'approval_flows' - ], - 'service' => [ - 'module' => 'crm', - 'name' => 'approval_flows' - ], - 'logic' => [ - 'module' => 'crm', - 'name' => 'approval_flows' - ], - 'validate' => [ - 'module' => 'crm', - 'name' => 'approval_flows' - ], - 'table' => 'approval_flows' - ]; + // $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' + // ]; - $result = (new Generate($options))->doGenerate(); - return $result; + // $result = (new Generate($options))->doGenerate(); + // return $result; } public function test(Request $request) { - // 菜单迁移 - // $where[] = ['id', '>', 141]; - // $list = (new \app\common\utils\MigrationResources())->do('resources_20250916_bak', 'resources_yuanshi', $where); - $list = (new \app\common\utils\MigrationResources())->getFullResourceTreeOptimized('resources_20250916_bak', []); - var_dump($list); + $info = \app\common\model\PurchaseOrders::find(14); + $service = new \app\common\service\PurchaseOrdersService(); + $result = $service->startApproval($info); + + return json($result); } } \ No newline at end of file diff --git a/app/crm/service/StockOrdersService.php b/app/crm/service/StockOrdersService.php index 9026af3..dd7d570 100644 --- a/app/crm/service/StockOrdersService.php +++ b/app/crm/service/StockOrdersService.php @@ -2,45 +2,12 @@ namespace app\crm\service; -use app\common\model\StockOrderAccounts; -use app\common\model\StockOrderItemOptions; -use app\common\model\StockOrderItems; -use app\common\validate\StockOrdersValidate; -use app\common\traits\crud\CrudServiceTrait; -use app\common\model\StockOrders; -use app\common\model\SysCurrency; use support\Request; -use app\common\model\User; -use support\Db; -use support\Model; -use app\common\model\ApprovalInstances; -use app\common\model\ApprovalRecords; use support\exception\BusinessException; -class StockOrdersService +class StockOrdersService extends \app\common\service\StockOrdersService { - use CrudServiceTrait; - - /** - * 指定当前要操作的模型 - */ - protected string $modelClass = StockOrders::class; - - /** - * 验证器 - */ - protected $validator = StockOrdersValidate::class; - - /** - * 获取验证器 - */ - public function getValidator() - { - return new $this->validator; - } - - protected function buildQueryConfig(Request $request, string $scene = 'index'): array { $p = $request->all(); @@ -97,302 +64,6 @@ class StockOrdersService return $config; } - public function beforeServiceStore($data, Request $request) - { - $userId = $request->user->uid; - - $data['order_no'] = generateStockNo(); - $data['biz_date'] = strtotime($data['biz_date']); - $data['eta_ship_date'] = strtotime($data['eta_ship_date']); - $data['latest_arrival_date'] = strtotime($data['latest_arrival_date']); - $data['salesperson_id'] = $userId; - $data['salesperson_snapshot'] = User::where('id', $userId)->value('name') ?? ''; - $data['approval_status'] = $data['approval_status'] == 1 ? 1 : 0; - - // if ($data['is_final'] == 1) { - $data['pick_up_date'] = $data['pick_up_date'] ? strtotime($data['pick_up_date']) : 0; - $data['collect_all_date'] = $data['collect_all_date'] ? strtotime($data['collect_all_date']) : 0; - // } - return $data; - } - - protected function afterServiceStore($res) - { - // 处理原单 - if ($res->is_final == 1 && $res->object_id > 0) { - $order = StockOrders::find($res->object_id); - $order->is_use = 1; - $order->object_id = $res->id; - if (!$order->save()) { - flg('备货单【'.$order->order_no.'】创建终版成功,但更新原单失败'); - } - } - // 启动审批 - if ($res->approval_status == 1) { - $this->startApproval($res); - } - return $res; - } - - /** - * 启动审批 - */ - public function startApproval($item) - { - // 启动审批 - $appro = new \app\common\utils\approval\service\ApprovalFlowService(); - $appro->start('stock_orders', 'stock_orders', $item->id, $item->toArray(), $item->salesperson_id); - // 异步生成pdf文件 - \Webman\RedisQueue\Redis::send('stock-order-pdf', ['id'=>$item->id]); - } - - /** - * 创建PDF文件 - */ - public function createdPdfFile($id) - { - $config = []; - $config['model'] = $this->modelClass; - $config['where']['id'] = $id; - $config['with'][] = 'items'; - $config['with'][] = 'items.options'; - $config['with'][] = 'items.moneyValues'; - $config['with'][] = 'accounts'; - $config['with'][] = 'moneyValues'; - $config['with'][] = 'salesperson'; - $item = $this->logicShow($config); - $pdfData = $this->pdfData($item); - - // 渲染模板为最终HTML - $tplPath = app_path('common/utils/pdf/template/stockOrder.stub'); - $html = \app\common\utils\pdf\service\PdfHelper::render($tplPath, ['data' => $pdfData]); - - // 生成PDF - $driver = new \app\common\utils\pdf\service\MpdfDriver(); - $pdfBin = $driver->htmlToPdf($html); - - // 存储 - $dir = public_path('pdf').'/'; - $fileName = ($pdfData['order_no'] ?? date('YmdHis')) . '.pdf'; - - // 本地保存 - \app\common\utils\pdf\service\PdfStorage::saveBinary( - $pdfBin, - $dir, - $fileName - ); - - // 上传云端 - $param = [ - 'path' => $dir . $fileName, - 'scene' => 'stock_orders', - 'category' => 'pdf', - 'original_name' => $fileName - ]; - $up = new \app\common\service\UploadService(); - $res = $up->uploadLocalFile($param); - - @unlink($dir . $fileName); - - // 更新文件地址 - $item->file_url = $res['file_name']; - $item->save(); - - return $res; - } - - public function pdfData($item) - { - $vehList = []; - $index = 0; - $totalProfit = 0; // 总利润 - - // $priceFields = [ - // 'official_price', // '官方指导价', - // 'discount_price', // '优惠', - // 'listing_price', // '二手挂牌价价', - // 'reduction_price', // '优惠', - - // 'handling_fee', // '手续费', - // 'retrofit_fee', // '改装费', - // 'fob_transport_fee', // 'FOB运输费', - // 'port_misc_fee', // '港杂', - // 'ocean_freight', // '海运费', - - // 'unit_price', // '单价', - // 'total_price', // '总价', - // // 'profit', // '利润' - // ]; - - foreach ($item->items as $k => $v) { - $currencyInfo = SysCurrency::where('currency_code', $item->settlement_currency)->first(['currency_symbol', 'currency_name'])->toArray(); - - // 存在则数量加1 - if (count($vehList) > 0 && isset($vehList[$v->unique_id])) { - $vehList[$v->unique_id]['num'] += 1; - $vehList[$v->unique_id]['total_price']['currency_info'] = $currencyInfo['currency_symbol'].number_format($vehList[$v->unique_id]['total_price']['amount']*$vehList[$v->unique_id]['num'], 2, '.', ','); - $vehList[$v->unique_id]['profit']['currency_info'] = $currencyInfo['currency_symbol'].number_format($vehList[$v->unique_id]['profit']['amount']*$vehList[$v->unique_id]['num'], 2, '.', ','); - continue; - } - - $index += 1; - - // 处理金额 (每次循环重新初始化) - $mv = []; - foreach ($v->moneyValues as $money) { - $mv[$money->field] = [ - 'currency' => $money->currency, - 'amount' => $money->amount, - 'rate' => $money->exchange_rate, - 'currency_info' => SysCurrency::where('currency_code', $money->currency)->value('currency_symbol').number_format($money->amount, 2, '.', ',') - ]; - } - - // 处理改装 (每次循环重新初始化) - $mfy = ''; - foreach ($v->options as $my) { - $mfy .= $my->option_name_snapshot.' '; - } - - // 计算单项利润并累加到总利润 - $itemProfit = $this->calculateProfit($mv, $item); - $totalProfit += $itemProfit; - - // 处理结果集 - $vehListItem = [ - 'index' => $index, - 'model_snapshot' => $v->model_snapshot, - 'color_interior_snapshot' => $v->color_interior_snapshot, - 'color_exterior_snapshot' => $v->color_exterior_snapshot, - 'num' => 1, - 'modify' => $mfy, - 'mileage' => $v->mileage - ]; - - $vehList[$v->unique_id] = $vehListItem + $mv; - } - - $mvs = []; - foreach ($item->moneyValues as $moneys) { - $mvs[$moneys->field] = [ - 'currency' => $moneys->currency, - 'amount' => $moneys->amount, - 'rate' => $moneys->exchange_rate, - 'currency_info' => SysCurrency::where('currency_code', $moneys->currency)->value('currency_symbol').number_format($moneys->amount, 2, '.', ','), - ]; - } - - $accList = []; - foreach ($item->accounts as $k => $v) { - $accList[] = [ - 'index' => '收款'.($k + 1), - 'remit_country_name_snapshot' => $v->remit_country_name_snapshot, - 'remit_date' => date('Y-m-d', $v->remit_date), - 'currency_name_snapshot' => $v->currency_name_snapshot, - 'account_name_snapshot' => $v->account_name_snapshot, - 'remitter_name' => $v->remitter_name, - 'amount' => $v->amount, - ]; - } - - - - $veh = [ - 'order_no' => $item->order_no, - 'salesperson_snapshot' => $item->salesperson_snapshot, - 'sign_img_url' => (!empty($item->salesperson->sign_img)) ? - (new \app\common\service\OssService)->generatePrivateUrl($item->salesperson->sign_img) : '', - 'created_at' => date('Y-m-d', strtotime($item->created_at)), - - 'pi_no' => $item->pi_no, - 'biz_date' => date('Y-m-d', $item->biz_date), - 'clearance_country_snapshot' => $item->clearance_country_snapshot, - 'customer_source_snapshot' => $item->customer_source_snapshot, - 'customer_snapshot' => $item->customer_snapshot, - 'customer_country_snapshot' => $item->customer_country_snapshot, - - // 订单项 - 'veh_list' => array_values($vehList), // 重新索引数组 - - 'contract_exchange_rate' => $item->contract_exchange_rate, - 'cif_total_amount' => $mvs['cif_total_amount'], - 'profit_total' => $mvs['profit_total'], - - 'declarant_name' => $item->declarant_name, - 'eta_ship_date' => date('Y-m-d', $item->eta_ship_date), - 'shipping_method_snapshot' => $item->shipping_method_snapshot, - 'domestic_port_snapshot' => $item->domestic_port_snapshot, - 'trade_term_snapshot' => $item->trade_term_snapshot, - 'clearance_port_snapshot' => $item->clearance_port_snapshot, - 'latest_arrival_date' => date('Y-m-d', $item->latest_arrival_date), - 'pick_up_date' => date('Y-m-d', $item->pick_up_date), - 'collect_all_date' => date('Y-m-d', $item->collect_all_date), - 'is_final' => $item->is_final, - - // 账户 - 'account_list' => $accList, - 'acc_index' => count($accList)+1, - 'currency_symbol' => $item->settlement_currency, - 'sign_imgs' => $this->queryAppro($item->id), - 'contract_url' => (!empty($item->contract)) ? (new \app\common\service\OssService)->generatePrivateUrl($item->contract) : '' - ]; - - return $veh; - } - - - public function queryAppro($bizId) - { - $instances = ApprovalInstances::where('biz_id', $bizId)->where('biz_type', 'stock_orders')->first(); - if (!$instances) { - return []; - } - - $records = ApprovalRecords::where('instance_id', $instances->id)->where('action', 'approve')->orderBy('step_index', 'asc')->pluck('approver_id')->toArray(); - if (count($records) == 0) { - return []; - } - - $signImgs = []; - foreach ($records as $k => $v) { - $signImg = User::where('id', $v)->value('sign_img'); - $signImgs[$k] = (!empty($signImg))?(new \app\common\service\OssService)->generatePrivateUrl($signImg):''; - } - - return $signImgs; - } - - /** - * 打印数据 - */ - public function servicePrint(Request $request) - { - $config = $this->buildQueryConfig($request, 'show'); - $item = $this->logicShow($config); - return $this->postProcess('Print', $item); - } - - public function serviceDetail(Request $request) - { - $config = $this->buildQueryConfig($request, 'detail'); - $item = $this->logicShow($config); - $pdfData = $this->pdfData($item); - return $this->postProcess('Detail', $pdfData); - } - - /** - * 审批 - */ - public function serviceApproval(Request $request) - { - $config = $this->buildQueryConfig($request, 'approval'); - $this->logicUpdateBy($config, [ - 'approval_status' => 1 - ]); - $item = $this->logicShow($config); - $this->startApproval($item); - return $this->postProcess('Approval', $item); - } /** * 最终审批 @@ -412,351 +83,4 @@ class StockOrdersService $this->startApproval($item); return $this->postProcess('UltimateApproval', $item); } - - /** - * 创建 - * - * @param Request $request - * @param array $data - * @return mixed - */ - public function serviceStore(Request $request, array $data = []) - { - // 关联表较多,增加事务处理 - return Db::transaction(function () use ($request, $data) { - $data = $this->preProcess('Store', $data, $request); - $dl = $this->getDataLimitFromRequest($request); - $created = $this->logicCreate($data, $dl); - - // 处理车辆和账户信息 - $this->processOrderItems($created, $data); - - return $this->postProcess('Store', $created); - }); - } - - protected function onlyFillableData(Model|string $model, array $data, array $extraWhitelist = []): array - { - // 兼容传类名或实例 - $m = \is_string($model) ? new $model : $model; - // 1) 优先使用 fillable - $keys = $m->getFillable(); - // 2) 若没配置 fillable(常见:用 guarded),回退到完整表字段,避免误伤 - if (empty($keys)) { - $keys = $m->getConnection() - ->getSchemaBuilder() - ->getColumnListing($m->getTable()); - } - // 3) 允许额外白名单(如虚拟字段、JSON映射字段) - if ($extraWhitelist) { - $keys = array_merge($keys, $extraWhitelist); - } - return array_intersect_key($data, array_flip($keys)); - } - - /** - * 更新前处理 - * - * @param array $data - * @param Request $request - * @return array - */ - public function beforeServiceUpdate($data, Request $request) - { - $data['biz_date'] = strtotime($data['biz_date']); - $data['eta_ship_date'] = strtotime($data['eta_ship_date']); - $data['latest_arrival_date'] = strtotime($data['latest_arrival_date']); - $data['approval_status'] = $data['approval_status'] == 1 ? 1 : 0; - - $data['pick_up_date'] = $data['pick_up_date'] ? strtotime($data['pick_up_date']) : 0; - $data['collect_all_date'] = $data['collect_all_date'] ? strtotime($data['collect_all_date']) : 0; - return $data; - } - - /** - * 更新后处理 - * - * @param mixed $res - * @return mixed - */ - protected function afterServiceUpdate($res) - { - // 启动审批 - if ($res->approval_status == 1) { - $this->startApproval($res); - } - return $res; - } - - /** - * 更新 - * - * @param Request $request - * @param array $data - * @return mixed - */ - public function serviceUpdate(Request $request, array $data = []) - { - // 关联表较多,增加事务处理 - return Db::transaction(function () use ($request, $data) { - $data = $this->preProcess('Update', $data, $request); - $config = $this->buildQueryConfig($request, 'update'); - // 过滤掉非库字段 - $dbData = $this->onlyFillableData($config['model'], $data); - // 更新主表 - $updated = $this->logicUpdateBy($config, $dbData); - // 获取更新后的数据 - $order = $this->logicShow($config); - // 处理车辆和账户信息 - $this->processOrderItems($order, $data, true); - return $this->postProcess('Update', $order); - }); - } - - /** - * 处理订单项(车辆和账户) - * @param StockOrders $order 订单对象 - * @param array $data 数据 - * @param bool $isUpdate 是否为更新操作 - */ - private function processOrderItems($order, array $data, bool $isUpdate = false): void - { - // 如果是更新操作,先删除原有的关联数据 - if ($isUpdate) { - $this->clearExistingOrderItems($order->id); - } - - // 处理车辆项 - $this->processVehicleItems($order, $data['veh_list'] ?? []); - - // 处理账户项 - $this->processAccountItems($order, $data['account_list'] ?? []); - } - - /** - * (更新时)清除已存在的订单项 - * @param int $orderId 订单ID - */ - private function clearExistingOrderItems(int $orderId): void - { - // 删除车辆改装项 - StockOrderItemOptions::whereHas('item', function ($query) use ($orderId) { - $query->where('order_id', $orderId); - })->delete(); - - // 删除车辆项及其相关金额记录 - $orderItems = StockOrderItems::where('order_id', $orderId)->get(); - foreach ($orderItems as $item) { - // 删除车辆项的金额记录 - Db::table('veh_money_value') - ->where('object_scene', 'stock_order_items') - ->where('object_id', $item->id) - ->delete(); - } - - // 删除车辆项 - StockOrderItems::where('order_id', $orderId)->delete(); - - // 删除订单本身的金额记录 - Db::table('veh_money_value') - ->where('object_scene', 'stock_orders') - ->where('object_id', $orderId) - ->delete(); - - // 删除账户项 - StockOrderAccounts::where('order_id', $orderId)->delete(); - } - - /** - * 处理车辆项 - */ - private function processVehicleItems($order, array $vehList): void - { - $cifTotalAmount = 0.00; - // $cifTotalAmountRate = 0; - // $cifTotalAmountCurrency = 'CNY'; - - $profitTotalAmount = 0.00; - foreach ($vehList as $veh) { - $veh['order_id'] = $order->id; - $veh['unique_id'] = $this->generateVehicleUniqueId($veh); - - $item = StockOrderItems::create($veh); - - // 处理金额信息 - $this->processVehiclePrices($item, $veh); - - // 计算利润 - $profit = $this->calculateProfit($veh, $order); - $item->addMoney('profit', $order->settlement_currency, $profit, $order->contract_exchange_rate, '利润'); - - // 累计到岸价 - $cifTotalAmount += $veh['total_price']['amount'] ?? 0; - $profitTotalAmount += $profit; - - // if (isset($veh['retrofit_fee']['rate'])) { - // $cifTotalAmountRate = $veh['retrofit_fee']['rate']; - // } - // if (isset($veh['retrofit_fee']['currency'])) { - // $cifTotalAmountCurrency = $veh['retrofit_fee']['currency']; - // } - - // 处理改装项 - $this->processModificationItems($item->id, $veh['modify_list'] ?? []); - } - - // 保存合计到岸价 - $order->addMoney('cif_total_amount', $order->settlement_currency, $cifTotalAmount, $order->contract_exchange_rate, '合计到岸价'); - // 利润 - $order->addMoney('profit_total', $order->settlement_currency, $profitTotalAmount, $order->contract_exchange_rate, '总利润'); - } - - /** - * 生成车辆唯一标识 - */ - private function generateVehicleUniqueId(array $veh): string - { - $uniqueIdStr = ""; - $uniqueIdStr .= $veh['sku_id'] ?? ''; - $uniqueIdStr .= $veh['model_snapshot'] ?? ''; - $uniqueIdStr .= $veh['color_interior_code'] ?? ''; - $uniqueIdStr .= $veh['color_exterior_code'] ?? ''; - $uniqueIdStr .= $veh['mileage'] ?? ''; - $uniqueIdStr .= json_encode($veh['modify_list'] ?? []); - - return md5($uniqueIdStr); - } - - /** - * 处理车辆价格信息 - */ - private function processVehiclePrices($item, array $veh): void - { - // 基础费用 - $priceFields = [ - 'unit_price' => '单价', - 'total_price' => '总价', - 'handling_fee' => '手续费', - 'fob_transport_fee' => 'FOB运输费', - 'port_misc_fee' => '港杂', - 'retrofit_fee' => '改装费', - 'ocean_freight' => '海运费' - ]; - - foreach ($priceFields as $field => $description) { - if (!empty($veh[$field]['amount'])) { - $item->addMoney( - $field, - $veh[$field]['currency'], - $veh[$field]['amount'], - $veh[$field]['rate'], - $description - ); - } - } - - // 根据里程处理新车或二手车价格 - if (!empty($veh['mileage']) && $veh['mileage'] > 0) { - // 二手车 - if (!empty($veh['listing_price']['amount'])) { - $item->addMoney( - 'listing_price', - $veh['listing_price']['currency'], - $veh['listing_price']['amount'], - $veh['listing_price']['rate'], - '二手挂牌价' - ); - } - - if (!empty($veh['reduction_price']['amount'])) { - $item->addMoney( - 'reduction_price', - $veh['reduction_price']['currency'], - $veh['reduction_price']['amount'], - $veh['reduction_price']['rate'], - '二手减价' - ); - } - } else { - // 新车 - if (!empty($veh['official_price']['amount'])) { - $item->addMoney( - 'official_price', - $veh['official_price']['currency'], - $veh['official_price']['amount'], - $veh['official_price']['rate'], - '官方指导价' - ); - } - - if (!empty($veh['discount_price']['amount'])) { - $item->addMoney( - 'discount_price', - $veh['discount_price']['currency'], - $veh['discount_price']['amount'], - $veh['discount_price']['rate'], - '优惠价' - ); - } - } - } - - /** - * 计算利润 - */ - private function calculateProfit(array $veh, $order): float - { - $unitPrice = (float)($veh['unit_price']['amount'] ?? 0); - - if (!empty($veh['mileage']) && $veh['mileage'] > 0) { - // 二手车 - $ttPrice = ($veh['listing_price']['amount'] ?? 0) / ($order->contract_exchange_rate ?: 1); - $ddPrice = $veh['reduction_price']['amount'] ?? 0; - } else { - // 新车 - $ttPrice = ($veh['official_price']['amount'] ?? 0) / ($order->contract_exchange_rate ?: 1); - $ddPrice = $veh['discount_price']['amount'] ?? 0; - } - - $handlingFee = (float)($veh['handling_fee']['amount'] ?? 0); - $fobTransportFee = (float)($veh['fob_transport_fee']['amount'] ?? 0); - $portMiscFee = (float)($veh['port_misc_fee']['amount'] ?? 0); - $retrofitFee = (float)($veh['retrofit_fee']['amount'] ?? 0); - $oceanFreight = (float)($veh['ocean_freight']['amount'] ?? 0); - - return $unitPrice - $ttPrice - $ddPrice - $handlingFee - $fobTransportFee - $portMiscFee - $retrofitFee - $oceanFreight; - } - - /** - * 处理改装项 - */ - private function processModificationItems(int $orderItemId, array $modifyList): void - { - if (empty($modifyList)) { - return; - } - - foreach ($modifyList as $modify) { - $modify['order_item_id'] = $orderItemId; - StockOrderItemOptions::create($modify); - } - } - - /** - * 处理账户项 - */ - private function processAccountItems($order, array $accountList): void - { - if (empty($accountList)) { - return; - } - - foreach ($accountList as $account) { - $account['order_id'] = $order->id; - if (!empty($account['remit_date'])) { - $account['remit_date'] = strtotime($account['remit_date']); - } - StockOrderAccounts::create($account); - } - } } \ No newline at end of file diff --git a/app/crm/service/StockOrdersService_bak2.php b/app/crm/service/StockOrdersService_bak2.php new file mode 100644 index 0000000..1c4f7ea --- /dev/null +++ b/app/crm/service/StockOrdersService_bak2.php @@ -0,0 +1,762 @@ +validator; + } + + + protected function buildQueryConfig(Request $request, string $scene = 'index'): array + { + $p = $request->all(); + + $config = []; + $config['model'] = $this->modelClass; + switch ($scene) { + case 'index': + + // ====== ====== 筛选 ====== ====== + // 业务id + if (isset($p['salesperson_id']) && $p['salesperson_id']) { + $config['where']['salesperson_id'] = $p['salesperson_id']; + } + // 初、终版 + $config['where']['is_final'] = $p['is_final'] ?? 0; + // 关键字 + if (isset($p['keyword']) && $p['keyword']) { + $k = '%' . $p['keyword'] . '%'; + foreach (['pi_no', 'order_no', 'customer_snapshot'] as $col) { + $config['orWhere'][] = [$col, 'like', $k]; + } + } + + $config['order']['id'] = 'desc'; + + $config['where']['salesperson_id'] = $request->user->uid; + break; + case 'print': + case 'approval': + $config['where']['id'] = $p['id']; + break; + case 'detail': + case 'show': + $config['where']['id'] = $p['id']; + + $config['with'][] = 'items'; + $config['with'][] = 'items.options'; + $config['with'][] = 'items.moneyValues'; + $config['with'][] = 'accounts'; + $config['with'][] = 'moneyValues'; + $config['with'][] = 'salesperson'; + break; + case 'store': + break; + case 'update': + $config['where']['id'] = $p['id']; + break; + case 'destroy': + $config['where']['id'] = $p['id']; + break; + } + + return $config; + } + + public function beforeServiceStore($data, Request $request) + { + $userId = $request->user->uid; + + $data['order_no'] = generateStockNo(); + $data['biz_date'] = strtotime($data['biz_date']); + $data['eta_ship_date'] = strtotime($data['eta_ship_date']); + $data['latest_arrival_date'] = strtotime($data['latest_arrival_date']); + $data['salesperson_id'] = $userId; + $data['salesperson_snapshot'] = User::where('id', $userId)->value('name') ?? ''; + $data['approval_status'] = $data['approval_status'] == 1 ? 1 : 0; + + // if ($data['is_final'] == 1) { + $data['pick_up_date'] = $data['pick_up_date'] ? strtotime($data['pick_up_date']) : 0; + $data['collect_all_date'] = $data['collect_all_date'] ? strtotime($data['collect_all_date']) : 0; + // } + return $data; + } + + protected function afterServiceStore($res) + { + // 处理原单 + if ($res->is_final == 1 && $res->object_id > 0) { + $order = StockOrders::find($res->object_id); + $order->is_use = 1; + $order->object_id = $res->id; + if (!$order->save()) { + flg('备货单【'.$order->order_no.'】创建终版成功,但更新原单失败'); + } + } + // 启动审批 + if ($res->approval_status == 1) { + $this->startApproval($res); + } + return $res; + } + + /** + * 启动审批 + */ + public function startApproval($item) + { + // 启动审批 + $appro = new \app\common\utils\approval\service\ApprovalFlowService(); + $appro->start('stock_orders', 'stock_orders', $item->id, [], $item->salesperson_id); + // 异步生成pdf文件 + \Webman\RedisQueue\Redis::send('stock-order-pdf', ['id'=>$item->id]); + } + + /** + * 创建PDF文件 + */ + public function createdPdfFile($id) + { + $config = []; + $config['model'] = $this->modelClass; + $config['where']['id'] = $id; + $config['with'][] = 'items'; + $config['with'][] = 'items.options'; + $config['with'][] = 'items.moneyValues'; + $config['with'][] = 'accounts'; + $config['with'][] = 'moneyValues'; + $config['with'][] = 'salesperson'; + $item = $this->logicShow($config); + $pdfData = $this->pdfData($item); + + // 渲染模板为最终HTML + $tplPath = app_path('common/utils/pdf/template/stockOrder.stub'); + $html = \app\common\utils\pdf\service\PdfHelper::render($tplPath, ['data' => $pdfData]); + + // 生成PDF + $driver = new \app\common\utils\pdf\service\MpdfDriver(); + $pdfBin = $driver->htmlToPdf($html); + + // 存储 + $dir = public_path('pdf').'/'; + $fileName = ($pdfData['order_no'] ?? date('YmdHis')) . '.pdf'; + + // 本地保存 + \app\common\utils\pdf\service\PdfStorage::saveBinary( + $pdfBin, + $dir, + $fileName + ); + + // 上传云端 + $param = [ + 'path' => $dir . $fileName, + 'scene' => 'stock_orders', + 'category' => 'pdf', + 'original_name' => $fileName + ]; + $up = new \app\common\service\UploadService(); + $res = $up->uploadLocalFile($param); + + @unlink($dir . $fileName); + + // 更新文件地址 + $item->file_url = $res['file_name']; + $item->save(); + + return $res; + } + + public function pdfData($item) + { + $vehList = []; + $index = 0; + $totalProfit = 0; // 总利润 + + // $priceFields = [ + // 'official_price', // '官方指导价', + // 'discount_price', // '优惠', + // 'listing_price', // '二手挂牌价价', + // 'reduction_price', // '优惠', + + // 'handling_fee', // '手续费', + // 'retrofit_fee', // '改装费', + // 'fob_transport_fee', // 'FOB运输费', + // 'port_misc_fee', // '港杂', + // 'ocean_freight', // '海运费', + + // 'unit_price', // '单价', + // 'total_price', // '总价', + // // 'profit', // '利润' + // ]; + + foreach ($item->items as $k => $v) { + $currencyInfo = SysCurrency::where('currency_code', $item->settlement_currency)->first(['currency_symbol', 'currency_name'])->toArray(); + + // 存在则数量加1 + if (count($vehList) > 0 && isset($vehList[$v->unique_id])) { + $vehList[$v->unique_id]['num'] += 1; + $vehList[$v->unique_id]['total_price']['currency_info'] = $currencyInfo['currency_symbol'].number_format($vehList[$v->unique_id]['total_price']['amount']*$vehList[$v->unique_id]['num'], 2, '.', ','); + $vehList[$v->unique_id]['profit']['currency_info'] = $currencyInfo['currency_symbol'].number_format($vehList[$v->unique_id]['profit']['amount']*$vehList[$v->unique_id]['num'], 2, '.', ','); + continue; + } + + $index += 1; + + // 处理金额 (每次循环重新初始化) + $mv = []; + foreach ($v->moneyValues as $money) { + $mv[$money->field] = [ + 'currency' => $money->currency, + 'amount' => $money->amount, + 'rate' => $money->exchange_rate, + 'currency_info' => SysCurrency::where('currency_code', $money->currency)->value('currency_symbol').number_format($money->amount, 2, '.', ',') + ]; + } + + // 处理改装 (每次循环重新初始化) + $mfy = ''; + foreach ($v->options as $my) { + $mfy .= $my->option_name_snapshot.' '; + } + + // 计算单项利润并累加到总利润 + $itemProfit = $this->calculateProfit($mv, $item); + $totalProfit += $itemProfit; + + // 处理结果集 + $vehListItem = [ + 'index' => $index, + 'model_snapshot' => $v->model_snapshot, + 'color_interior_snapshot' => $v->color_interior_snapshot, + 'color_exterior_snapshot' => $v->color_exterior_snapshot, + 'num' => 1, + 'modify' => $mfy, + 'mileage' => $v->mileage + ]; + + $vehList[$v->unique_id] = $vehListItem + $mv; + } + + $mvs = []; + foreach ($item->moneyValues as $moneys) { + $mvs[$moneys->field] = [ + 'currency' => $moneys->currency, + 'amount' => $moneys->amount, + 'rate' => $moneys->exchange_rate, + 'currency_info' => SysCurrency::where('currency_code', $moneys->currency)->value('currency_symbol').number_format($moneys->amount, 2, '.', ','), + ]; + } + + $accList = []; + foreach ($item->accounts as $k => $v) { + $accList[] = [ + 'index' => '收款'.($k + 1), + 'remit_country_name_snapshot' => $v->remit_country_name_snapshot, + 'remit_date' => date('Y-m-d', $v->remit_date), + 'currency_name_snapshot' => $v->currency_name_snapshot, + 'account_name_snapshot' => $v->account_name_snapshot, + 'remitter_name' => $v->remitter_name, + 'amount' => $v->amount, + ]; + } + + + + $veh = [ + 'order_no' => $item->order_no, + 'salesperson_snapshot' => $item->salesperson_snapshot, + 'sign_img_url' => (!empty($item->salesperson->sign_img)) ? + (new \app\common\service\OssService)->generatePrivateUrl($item->salesperson->sign_img) : '', + 'created_at' => date('Y-m-d', strtotime($item->created_at)), + + 'pi_no' => $item->pi_no, + 'biz_date' => date('Y-m-d', $item->biz_date), + 'clearance_country_snapshot' => $item->clearance_country_snapshot, + 'customer_source_snapshot' => $item->customer_source_snapshot, + 'customer_snapshot' => $item->customer_snapshot, + 'customer_country_snapshot' => $item->customer_country_snapshot, + + // 订单项 + 'veh_list' => array_values($vehList), // 重新索引数组 + + 'contract_exchange_rate' => $item->contract_exchange_rate, + 'cif_total_amount' => $mvs['cif_total_amount'], + 'profit_total' => $mvs['profit_total'], + + 'declarant_name' => $item->declarant_name, + 'eta_ship_date' => date('Y-m-d', $item->eta_ship_date), + 'shipping_method_snapshot' => $item->shipping_method_snapshot, + 'domestic_port_snapshot' => $item->domestic_port_snapshot, + 'trade_term_snapshot' => $item->trade_term_snapshot, + 'clearance_port_snapshot' => $item->clearance_port_snapshot, + 'latest_arrival_date' => date('Y-m-d', $item->latest_arrival_date), + 'pick_up_date' => date('Y-m-d', $item->pick_up_date), + 'collect_all_date' => date('Y-m-d', $item->collect_all_date), + 'is_final' => $item->is_final, + + // 账户 + 'account_list' => $accList, + 'acc_index' => count($accList)+1, + 'currency_symbol' => $item->settlement_currency, + 'sign_imgs' => $this->queryAppro($item->id), + 'contract_url' => (!empty($item->contract)) ? (new \app\common\service\OssService)->generatePrivateUrl($item->contract) : '' + ]; + + return $veh; + } + + + public function queryAppro($bizId) + { + $instances = ApprovalInstances::where('biz_id', $bizId)->where('biz_type', 'stock_orders')->first(); + if (!$instances) { + return []; + } + + $records = ApprovalRecords::where('instance_id', $instances->id)->where('action', 'approve')->orderBy('step_index', 'asc')->pluck('approver_id')->toArray(); + if (count($records) == 0) { + return []; + } + + $signImgs = []; + foreach ($records as $k => $v) { + $signImg = User::where('id', $v)->value('sign_img'); + $signImgs[$k] = (!empty($signImg))?(new \app\common\service\OssService)->generatePrivateUrl($signImg):''; + } + + return $signImgs; + } + + /** + * 打印数据 + */ + public function servicePrint(Request $request) + { + $config = $this->buildQueryConfig($request, 'show'); + $item = $this->logicShow($config); + return $this->postProcess('Print', $item); + } + + public function serviceDetail(Request $request) + { + $config = $this->buildQueryConfig($request, 'detail'); + $item = $this->logicShow($config); + $pdfData = $this->pdfData($item); + return $this->postProcess('Detail', $pdfData); + } + + /** + * 审批 + */ + public function serviceApproval(Request $request) + { + $config = $this->buildQueryConfig($request, 'approval'); + $this->logicUpdateBy($config, [ + 'approval_status' => 1 + ]); + $item = $this->logicShow($config); + $this->startApproval($item); + return $this->postProcess('Approval', $item); + } + + /** + * 最终审批 + */ + public function serviceUltimateApproval(Request $request) + { + $config = $this->buildQueryConfig($request, 'detail'); + $item = $this->logicShow($config); + foreach ($item->items as $k => $v) { + if ($v->vin === '') { + throw new BusinessException('存在未填写vin的车辆,无法完成提交审核'); + } + } + $this->logicUpdateBy($config, [ + 'approval_status' => 1 + ]); + $this->startApproval($item); + return $this->postProcess('UltimateApproval', $item); + } + + /** + * 创建 + * + * @param Request $request + * @param array $data + * @return mixed + */ + public function serviceStore(Request $request, array $data = []) + { + // 关联表较多,增加事务处理 + return Db::transaction(function () use ($request, $data) { + $data = $this->preProcess('Store', $data, $request); + $dl = $this->getDataLimitFromRequest($request); + $created = $this->logicCreate($data, $dl); + + // 处理车辆和账户信息 + $this->processOrderItems($created, $data); + + return $this->postProcess('Store', $created); + }); + } + + protected function onlyFillableData(Model|string $model, array $data, array $extraWhitelist = []): array + { + // 兼容传类名或实例 + $m = \is_string($model) ? new $model : $model; + // 1) 优先使用 fillable + $keys = $m->getFillable(); + // 2) 若没配置 fillable(常见:用 guarded),回退到完整表字段,避免误伤 + if (empty($keys)) { + $keys = $m->getConnection() + ->getSchemaBuilder() + ->getColumnListing($m->getTable()); + } + // 3) 允许额外白名单(如虚拟字段、JSON映射字段) + if ($extraWhitelist) { + $keys = array_merge($keys, $extraWhitelist); + } + return array_intersect_key($data, array_flip($keys)); + } + + /** + * 更新前处理 + * + * @param array $data + * @param Request $request + * @return array + */ + public function beforeServiceUpdate($data, Request $request) + { + $data['biz_date'] = strtotime($data['biz_date']); + $data['eta_ship_date'] = strtotime($data['eta_ship_date']); + $data['latest_arrival_date'] = strtotime($data['latest_arrival_date']); + $data['approval_status'] = $data['approval_status'] == 1 ? 1 : 0; + + $data['pick_up_date'] = $data['pick_up_date'] ? strtotime($data['pick_up_date']) : 0; + $data['collect_all_date'] = $data['collect_all_date'] ? strtotime($data['collect_all_date']) : 0; + return $data; + } + + /** + * 更新后处理 + * + * @param mixed $res + * @return mixed + */ + protected function afterServiceUpdate($res) + { + // 启动审批 + if ($res->approval_status == 1) { + $this->startApproval($res); + } + return $res; + } + + /** + * 更新 + * + * @param Request $request + * @param array $data + * @return mixed + */ + public function serviceUpdate(Request $request, array $data = []) + { + // 关联表较多,增加事务处理 + return Db::transaction(function () use ($request, $data) { + $data = $this->preProcess('Update', $data, $request); + $config = $this->buildQueryConfig($request, 'update'); + // 过滤掉非库字段 + $dbData = $this->onlyFillableData($config['model'], $data); + // 更新主表 + $updated = $this->logicUpdateBy($config, $dbData); + // 获取更新后的数据 + $order = $this->logicShow($config); + // 处理车辆和账户信息 + $this->processOrderItems($order, $data, true); + return $this->postProcess('Update', $order); + }); + } + + /** + * 处理订单项(车辆和账户) + * @param StockOrders $order 订单对象 + * @param array $data 数据 + * @param bool $isUpdate 是否为更新操作 + */ + private function processOrderItems($order, array $data, bool $isUpdate = false): void + { + // 如果是更新操作,先删除原有的关联数据 + if ($isUpdate) { + $this->clearExistingOrderItems($order->id); + } + + // 处理车辆项 + $this->processVehicleItems($order, $data['veh_list'] ?? []); + + // 处理账户项 + $this->processAccountItems($order, $data['account_list'] ?? []); + } + + /** + * (更新时)清除已存在的订单项 + * @param int $orderId 订单ID + */ + private function clearExistingOrderItems(int $orderId): void + { + // 删除车辆改装项 + StockOrderItemOptions::whereHas('item', function ($query) use ($orderId) { + $query->where('order_id', $orderId); + })->delete(); + + // 删除车辆项及其相关金额记录 + $orderItems = StockOrderItems::where('order_id', $orderId)->get(); + foreach ($orderItems as $item) { + // 删除车辆项的金额记录 + Db::table('veh_money_value') + ->where('object_scene', 'stock_order_items') + ->where('object_id', $item->id) + ->delete(); + } + + // 删除车辆项 + StockOrderItems::where('order_id', $orderId)->delete(); + + // 删除订单本身的金额记录 + Db::table('veh_money_value') + ->where('object_scene', 'stock_orders') + ->where('object_id', $orderId) + ->delete(); + + // 删除账户项 + StockOrderAccounts::where('order_id', $orderId)->delete(); + } + + /** + * 处理车辆项 + */ + private function processVehicleItems($order, array $vehList): void + { + $cifTotalAmount = 0.00; + // $cifTotalAmountRate = 0; + // $cifTotalAmountCurrency = 'CNY'; + + $profitTotalAmount = 0.00; + foreach ($vehList as $veh) { + $veh['order_id'] = $order->id; + $veh['unique_id'] = $this->generateVehicleUniqueId($veh); + + $item = StockOrderItems::create($veh); + + // 处理金额信息 + $this->processVehiclePrices($item, $veh); + + // 计算利润 + $profit = $this->calculateProfit($veh, $order); + $item->addMoney('profit', $order->settlement_currency, $profit, $order->contract_exchange_rate, '利润'); + + // 累计到岸价 + $cifTotalAmount += $veh['total_price']['amount'] ?? 0; + $profitTotalAmount += $profit; + + // if (isset($veh['retrofit_fee']['rate'])) { + // $cifTotalAmountRate = $veh['retrofit_fee']['rate']; + // } + // if (isset($veh['retrofit_fee']['currency'])) { + // $cifTotalAmountCurrency = $veh['retrofit_fee']['currency']; + // } + + // 处理改装项 + $this->processModificationItems($item->id, $veh['modify_list'] ?? []); + } + + // 保存合计到岸价 + $order->addMoney('cif_total_amount', $order->settlement_currency, $cifTotalAmount, $order->contract_exchange_rate, '合计到岸价'); + // 利润 + $order->addMoney('profit_total', $order->settlement_currency, $profitTotalAmount, $order->contract_exchange_rate, '总利润'); + } + + /** + * 生成车辆唯一标识 + */ + private function generateVehicleUniqueId(array $veh): string + { + $uniqueIdStr = ""; + $uniqueIdStr .= $veh['sku_id'] ?? ''; + $uniqueIdStr .= $veh['model_snapshot'] ?? ''; + $uniqueIdStr .= $veh['color_interior_code'] ?? ''; + $uniqueIdStr .= $veh['color_exterior_code'] ?? ''; + $uniqueIdStr .= $veh['mileage'] ?? ''; + $uniqueIdStr .= json_encode($veh['modify_list'] ?? []); + + return md5($uniqueIdStr); + } + + /** + * 处理车辆价格信息 + */ + private function processVehiclePrices($item, array $veh): void + { + // 基础费用 + $priceFields = [ + 'unit_price' => '单价', + 'total_price' => '总价', + 'handling_fee' => '手续费', + 'fob_transport_fee' => 'FOB运输费', + 'port_misc_fee' => '港杂', + 'retrofit_fee' => '改装费', + 'ocean_freight' => '海运费' + ]; + + foreach ($priceFields as $field => $description) { + if (!empty($veh[$field]['amount'])) { + $item->addMoney( + $field, + $veh[$field]['currency'], + $veh[$field]['amount'], + $veh[$field]['rate'], + $description + ); + } + } + + // 根据里程处理新车或二手车价格 + if (!empty($veh['mileage']) && $veh['mileage'] > 0) { + // 二手车 + if (!empty($veh['listing_price']['amount'])) { + $item->addMoney( + 'listing_price', + $veh['listing_price']['currency'], + $veh['listing_price']['amount'], + $veh['listing_price']['rate'], + '二手挂牌价' + ); + } + + if (!empty($veh['reduction_price']['amount'])) { + $item->addMoney( + 'reduction_price', + $veh['reduction_price']['currency'], + $veh['reduction_price']['amount'], + $veh['reduction_price']['rate'], + '二手减价' + ); + } + } else { + // 新车 + if (!empty($veh['official_price']['amount'])) { + $item->addMoney( + 'official_price', + $veh['official_price']['currency'], + $veh['official_price']['amount'], + $veh['official_price']['rate'], + '官方指导价' + ); + } + + if (!empty($veh['discount_price']['amount'])) { + $item->addMoney( + 'discount_price', + $veh['discount_price']['currency'], + $veh['discount_price']['amount'], + $veh['discount_price']['rate'], + '优惠价' + ); + } + } + } + + /** + * 计算利润 + */ + private function calculateProfit(array $veh, $order): float + { + $unitPrice = (float)($veh['unit_price']['amount'] ?? 0); + + if (!empty($veh['mileage']) && $veh['mileage'] > 0) { + // 二手车 + $ttPrice = ($veh['listing_price']['amount'] ?? 0) / ($order->contract_exchange_rate ?: 1); + $ddPrice = $veh['reduction_price']['amount'] ?? 0; + } else { + // 新车 + $ttPrice = ($veh['official_price']['amount'] ?? 0) / ($order->contract_exchange_rate ?: 1); + $ddPrice = $veh['discount_price']['amount'] ?? 0; + } + + $handlingFee = (float)($veh['handling_fee']['amount'] ?? 0); + $fobTransportFee = (float)($veh['fob_transport_fee']['amount'] ?? 0); + $portMiscFee = (float)($veh['port_misc_fee']['amount'] ?? 0); + $retrofitFee = (float)($veh['retrofit_fee']['amount'] ?? 0); + $oceanFreight = (float)($veh['ocean_freight']['amount'] ?? 0); + + return $unitPrice - $ttPrice - $ddPrice - $handlingFee - $fobTransportFee - $portMiscFee - $retrofitFee - $oceanFreight; + } + + /** + * 处理改装项 + */ + private function processModificationItems(int $orderItemId, array $modifyList): void + { + if (empty($modifyList)) { + return; + } + + foreach ($modifyList as $modify) { + $modify['order_item_id'] = $orderItemId; + StockOrderItemOptions::create($modify); + } + } + + /** + * 处理账户项 + */ + private function processAccountItems($order, array $accountList): void + { + if (empty($accountList)) { + return; + } + + foreach ($accountList as $account) { + $account['order_id'] = $order->id; + if (!empty($account['remit_date'])) { + $account['remit_date'] = strtotime($account['remit_date']); + } + StockOrderAccounts::create($account); + } + } +} \ No newline at end of file diff --git a/app/functions.php b/app/functions.php index 7ae0d0a..21e53c9 100644 --- a/app/functions.php +++ b/app/functions.php @@ -119,7 +119,8 @@ if (!function_exists('flg')) { if ($isFs) { $data = [ 'content' => $eventData, - 'time' => date('Y-m-d H:i:s', time()) + 'time' => date('Y-m-d H:i:s', time()), + 'source' => getenv('ENVIRONMENT') ]; RedisQueueRedis::send("server", $data); } diff --git a/app/queue/redis/ApprovalCallback.php b/app/queue/redis/ApprovalCallback.php new file mode 100644 index 0000000..58c0a1f --- /dev/null +++ b/app/queue/redis/ApprovalCallback.php @@ -0,0 +1,82 @@ + $this->approvalStockOrders($data), + 'purchase_orders' => $this->approvalPurchaseOrders($data), + default => throw new BusinessException('未知的审批类型,无法执行回调操作') + }; + return true; + } catch (\Throwable $th) { + flg('审批回调异常:'.$th->getFile()."|".$th->getLine()."|".$th->getMessage(), false); + throw new BusinessException($th->getFile()."|".$th->getLine()."|".$th->getMessage()); + } + } + + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + } + + /** + * 备货单处理 + */ + private function approvalStockOrders($data) + { + // 生成PDF文件 + $service = new \app\crm\service\StockOrdersService(); + $result = $service->createdPdfFile($data['biz_id']); + return $result; + } + + /** + * 采购单处理 + */ + private function approvalPurchaseOrders($data) + { + $udata['id'] = $data['biz_id']; + if ($data['status'] === 'approve') { + $udata['status'] = 2; + } + + if ($data['status'] === 'reject') { + $udata['status'] = 3; + } + + $service = new \app\common\service\PurchaseOrdersService(); + $result = $service->updateStatus($udata); + return $result; + } +} \ No newline at end of file diff --git a/app/queue/redis/Server.php b/app/queue/redis/Server.php index d425ea7..2d9814e 100644 --- a/app/queue/redis/Server.php +++ b/app/queue/redis/Server.php @@ -19,10 +19,12 @@ class Server implements Consumer public function consume($data) { try { + $source = $data['source'] === 'development' ? '-- 开发环境 --' : '** 生产环境 **'; $webhookUrl = 'https://open.feishu.cn/open-apis/bot/v2/hook/806c2e9d-6ca2-4345-a459-69bb2651706b'; $message = "====== ======\n"; $message .= $data['content']."\n"; $message .= "时间:".date('Y-m-d H:i:s', time())."\n"; + $message .= "来源:".$source."\n"; $message .= "====== ======"; sendFeishuTextMessage($webhookUrl, $message); return true; diff --git a/config/plugin/webman/gateway-worker/process.php b/config/plugin/webman/gateway-worker/process.php index 64796e9..a429973 100644 --- a/config/plugin/webman/gateway-worker/process.php +++ b/config/plugin/webman/gateway-worker/process.php @@ -22,7 +22,7 @@ return [ ], 'worker' => [ 'handler' => BusinessWorker::class, - 'count' => cpu_count()*2, + 'count' => 6, 'constructor' => ['config' => [ 'eventHandler' => plugin\webman\gateway\Events::class, 'name' => 'ChatBusinessWorker', diff --git a/config/route.php b/config/route.php index 887c683..4049182 100644 --- a/config/route.php +++ b/config/route.php @@ -317,6 +317,9 @@ Route::group('/admin', function () { Route::any('/action', [app\admin\controller\ApprovalRecordsController::class, 'approve']); Route::any('/todo', [app\admin\controller\ApprovalRecordsController::class, 'todo']); + Route::any('/border', [app\admin\controller\ApprovalRecordsController::class, 'businessOrder']); + Route::any('/transfer', [app\admin\controller\ApprovalRecordsController::class, 'transferApproval']); + Route::any('/timeline', [app\admin\controller\ApprovalRecordsController::class, 'approvalTimeline']); }); Route::group('/stock/order', function () { @@ -351,6 +354,17 @@ Route::group('/admin', function () { Route::any('/user/achieved', [app\admin\targets\controller\StatisticsController::class, 'userAchieved']); }); }); + + + Route::group('/purchase/order', function () { + Route::any('/predata', [app\common\controller\PurchaseOrdersController::class, 'preQueryData']); + Route::any('/list', [app\common\controller\PurchaseOrdersController::class, 'index']); + Route::any('/show', [app\common\controller\PurchaseOrdersController::class, 'show']); + Route::any('/store', [app\common\controller\PurchaseOrdersController::class, 'store']); + Route::any('/update', [app\common\controller\PurchaseOrdersController::class, 'update']); + Route::any('/destroy', [app\common\controller\PurchaseOrdersController::class, 'destroy']); + Route::any('/resubmit', [app\common\controller\PurchaseOrdersController::class, 'resubmit']); + }); }); From 223d57483c8226531f7afa07fb2c68b72edc29b6 Mon Sep 17 00:00:00 2001 From: mzl Date: Fri, 26 Sep 2025 15:52:45 +0800 Subject: [PATCH 02/21] Merge branch 'develop' # Conflicts: # app/controller/IndexController.php --- app/bootstrap/Audit.php | 31 ++++++++ app/common/audit/AuditBuffer.php | 77 +++++++++++++++++++ app/common/logic/AuthLogic.php | 1 + app/common/traits/Auditable.php | 48 ++++++++++++ app/controller/IndexController.php | 44 ++++++++++- app/middleware/Audit.php | 114 +++++++++++++++++++++++++++++ app/process/CheckWeb.php | 62 ++++++++++++++++ app/queue/redis/AuditLog.php | 72 ++++++++++++++++++ config/bootstrap.php | 3 + config/middleware.php | 3 +- 10 files changed, 450 insertions(+), 5 deletions(-) create mode 100644 app/bootstrap/Audit.php create mode 100644 app/common/audit/AuditBuffer.php create mode 100644 app/common/traits/Auditable.php create mode 100644 app/middleware/Audit.php create mode 100644 app/process/CheckWeb.php create mode 100644 app/queue/redis/AuditLog.php diff --git a/app/bootstrap/Audit.php b/app/bootstrap/Audit.php new file mode 100644 index 0000000..f94327c --- /dev/null +++ b/app/bootstrap/Audit.php @@ -0,0 +1,31 @@ +pushModelChange($model->getTable(), $model->getKey(), [], $model->getAttributes()); + }); + Model::updated(function ($model) { + $dirty = $model->getDirty(); + if (!$dirty) return; + $old = []; $new = []; + foreach ($dirty as $k=>$v) { + $old[$k] = $model->getOriginal($k); + $new[$k] = $model->getAttribute($k); + } + AuditBuffer::instance()->pushModelChange($model->getTable(), $model->getKey(), $old, $new); + }); + Model::deleted(function ($model) { + AuditBuffer::instance()->pushModelChange($model->getTable(), $model->getKey(), $model->getAttributes(), []); + }); + } + +} \ No newline at end of file diff --git a/app/common/audit/AuditBuffer.php b/app/common/audit/AuditBuffer.php new file mode 100644 index 0000000..bdb9974 --- /dev/null +++ b/app/common/audit/AuditBuffer.php @@ -0,0 +1,77 @@ +action = $action; + } + + public function setMeta(array $meta): void + { + $this->meta = $meta + $this->meta; + } + + /** 模型字段级变更推送(由 Trait 调用) */ + public function pushModelChange(string $scene, $objectId, array $old, array $new): void + { + $keys = array_unique(array_merge(array_keys($old), array_keys($new))); + foreach ($keys as $k) { + $ov = $old[$k] ?? null; + $nv = $new[$k] ?? null; + if ($ov === $nv) continue; + + [$ovMasked, $nvMasked] = $this->maskIfNeeded($k, $ov, $nv); + $this->changes[] = [ + 'scene' => $scene, + 'object_id' => $objectId, + 'field' => $k, + 'old' => $ovMasked, + 'new' => $nvMasked, + ]; + } + } + + private function maskIfNeeded(string $field, $old, $new): array + { + foreach ($this->maskFields as $m) { + if (stripos($field, $m) !== false) { + return [$old===null?null:'***', $new===null?null:'***']; + } + } + if (preg_match('/(mobile|phone|tel|idcard|id_card)/i', $field)) { + $mask = function ($v) { + if ($v === null) return null; + $s = (string)$v; + return mb_substr($s, 0, 3).'****'.mb_substr($s, -4); + }; + return [$mask($old), $mask($new)]; + } + return [$old, $new]; + } +} \ No newline at end of file diff --git a/app/common/logic/AuthLogic.php b/app/common/logic/AuthLogic.php index c79b1d1..ee449b9 100644 --- a/app/common/logic/AuthLogic.php +++ b/app/common/logic/AuthLogic.php @@ -425,6 +425,7 @@ class AuthLogic 'exp' => $exp, 'typ' => 'access', 'uid' => $user->id, + 'uname' => $user->name, 'role_ids' => $role_ids, 'system_ids' => $system_ids ]; diff --git a/app/common/traits/Auditable.php b/app/common/traits/Auditable.php new file mode 100644 index 0000000..e66341a --- /dev/null +++ b/app/common/traits/Auditable.php @@ -0,0 +1,48 @@ +pushModelChange(self::scene($m), $m->getKey(), [], $m->getAttributes()); + }); + + static::updated(function (Model $m) { + if (self::isMuted($m)) return; + $dirty = $m->getDirty(); + if (!$dirty) return; + $old = []; $new = []; + foreach (array_keys($dirty) as $k) { + $old[$k] = $m->getOriginal($k); + $new[$k] = $m->getAttribute($k); + } + AuditBuffer::instance()->pushModelChange(self::scene($m), $m->getKey(), $old, $new); + }); + + static::deleted(function (Model $m) { + if (self::isMuted($m)) return; + AuditBuffer::instance()->pushModelChange(self::scene($m), $m->getKey(), $m->getAttributes(), []); + }); + } + + protected static function scene(Model $m): string + { + return property_exists($m, 'auditScene') && $m->auditScene ? $m->auditScene : $m->getTable(); + } + + protected static function isMuted(Model $m): bool + { + if (property_exists($m, 'auditDisabled') && $m->auditDisabled) return true; + return $m->getTable() === 'sys_op_log'; // 避免自记录 + } +} \ No newline at end of file diff --git a/app/controller/IndexController.php b/app/controller/IndexController.php index fb87763..a72264c 100644 --- a/app/controller/IndexController.php +++ b/app/controller/IndexController.php @@ -10,6 +10,7 @@ use support\Request; use support\Db; use support\Cache; use support\Redis; +use GuzzleHttp\Client; class IndexController { @@ -101,11 +102,46 @@ class IndexController public function test(Request $request) { + $result = $this->tocurl('https://magicbridgeauto.com'); + if (preg_match('/magicbridgeauto/', $result['body'])) { + echo "未被篡改"; + flg("【magicbridgeauto】未被篡改"); + } else { + echo "已被篡改"; + flg("【magicbridgeauto】已被篡改"); + } + } - $info = \app\common\model\PurchaseOrders::find(14); - $service = new \app\common\service\PurchaseOrdersService(); - $result = $service->startApproval($info); - return json($result); + public function tocurl($url, $header = null, $content = null) + { + $ch = curl_init(); + if(substr($url,0,5)=='https'){ + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 跳过证书检查 + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); // 从证书中检查SSL加密算法是否存在 + curl_setopt($ch, CURLOPT_SSLVERSION, 1); + } + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_URL, $url); + if (is_array($header)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $header); + } + curl_setopt($ch, CURLOPT_POST, true); + if (!empty($content)) { + if (is_array($content)) { + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($content)); + } else if (is_string($content)) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $content); + } + } + $response = curl_exec($ch); + $error=curl_error($ch); + if($error){ + die($error); + } + $header = curl_getinfo($ch); + curl_close($ch); + $data = array('header' => $header,'body' => $response); + return $data; } } \ No newline at end of file diff --git a/app/middleware/Audit.php b/app/middleware/Audit.php new file mode 100644 index 0000000..deb99d5 --- /dev/null +++ b/app/middleware/Audit.php @@ -0,0 +1,114 @@ +getStatusCode(); + return $response; + } catch (\Throwable $e) { + $httpCode = $e->getCode() ?: 500; + $message = mb_substr($e->getMessage(), 0, 180); + $status = 0; + throw $e; + } finally { + $latency = (int)((microtime(true) - $start) * 1000); + $this->enqueue($request, $httpCode, $status, $message, $latency); + } + } + + + protected function enqueue(Request $req, int $httpCode, int $status, ?string $message, int $latencyMs): void + { + $buf = AuditBuffer::instance(); + + $user = $this->currentUser($req->user); + $paramsBrief = $this->briefParams($req->all()); + $ua = (string)($req->header('user-agent') ?? ''); + if (mb_strlen($ua) > 191) $ua = mb_substr($ua, 0, 191); + + $payload = [ + 'user_id' => $user['id'] ?? null, + 'user_name' => $user['name'] ?? null, + 'route' => $req->path(), + 'method' => $req->method(), + 'ip' => $req->getRealIp(), + 'ua' => $ua, + 'action' => $buf->action ?? $req->action, + 'status' => $status, + 'http_code' => $httpCode, + 'latency_ms' => $latencyMs, + 'params_brief' => $paramsBrief, // 数组,消费者再编码 + 'changes' => $buf->changes, // 数组,消费者再编码 + 'message' => $message, + 'ts' => time() + ]; + + try { + Queue::send('audit_log', $payload); // 异步 + } catch (\Throwable $e) { + // 遵循“绝不回落同步写”,仅记录错误 + Log::error('audit enqueue failed: '.$e->getMessage()); + } + } + + protected function briefParams(array $params): array + { + // $mask = ['password','pwd','token','secret','access_token','refresh_token','file','content','html']; + $mask = []; + $out = []; + foreach ($params as $k=>$v) { + if (in_array($k, $mask, true)) { $out[$k] = '***'; continue; } + if (is_array($v)) { $out[$k] = '[array('.count($v).')]'; continue; } + $s = (string)$v; + $out[$k] = mb_strlen($s) > 120 ? mb_substr($s, 0, 120).'…' : $s; + } + return $out; + } + + protected function inferAction(Request $req): ?string + { + $m = strtoupper($req->method()); + if ($m === 'POST') return 'create'; + if ($m === 'PUT' || $m === 'PATCH') return 'update'; + if ($m === 'DELETE') return 'delete'; + return 'view'; + } + + protected function currentUser($user): array + { + $user = (array)$user; + + if (!$user) { + return [ + 'id' => 0, + 'name' => 'guest' + ]; + } else { + if (!isset($user['uname'])) { + $user['uname'] = User::where('id', $user['uid'])->value('name'); + } + } + + return [ + 'id' => $user['uid'] ?? 0, + 'name' => $user['uname'] ?? 'guest' + ]; + } +} \ No newline at end of file diff --git a/app/process/CheckWeb.php b/app/process/CheckWeb.php new file mode 100644 index 0000000..c71a625 --- /dev/null +++ b/app/process/CheckWeb.php @@ -0,0 +1,62 @@ +check(); + }); + } + } + + + public function check() + { + $result = $this->tocurl('https://magicbridgeauto.com'); + if (preg_match('/magicbridgeauto/', $result['body'])) { + flg("【magicbridgeauto】未检查到异常"); + } else { + flg("【magicbridgeauto】疑似被篡改"); + } + } + + + public function tocurl($url, $header = null, $content = null) + { + $ch = curl_init(); + if(substr($url,0,5)=='https'){ + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 跳过证书检查 + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); // 从证书中检查SSL加密算法是否存在 + curl_setopt($ch, CURLOPT_SSLVERSION, 1); + } + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_URL, $url); + if (is_array($header)) { + curl_setopt($ch, CURLOPT_HTTPHEADER, $header); + } + curl_setopt($ch, CURLOPT_POST, true); + if (!empty($content)) { + if (is_array($content)) { + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($content)); + } else if (is_string($content)) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $content); + } + } + $response = curl_exec($ch); + $error=curl_error($ch); + if($error){ + die($error); + } + $header = curl_getinfo($ch); + curl_close($ch); + $data = array('header' => $header,'body' => $response); + return $data; + } +} \ No newline at end of file diff --git a/app/queue/redis/AuditLog.php b/app/queue/redis/AuditLog.php new file mode 100644 index 0000000..dc8ef88 --- /dev/null +++ b/app/queue/redis/AuditLog.php @@ -0,0 +1,72 @@ + $data['trace_id'] ?? null, + 'user_id' => $data['user_id'] ?? null, + 'user_name' => $data['user_name'] ?? null, + 'route' => $data['route'] ?? null, + 'method' => $data['method'] ?? null, + 'ip' => $data['ip'] ?? null, + 'ua' => $data['ua'] ?? null, + 'action' => $data['action'] ?? null, + 'status' => (int)($data['status'] ?? 1), + 'http_code' => (int)($data['http_code'] ?? 200), + 'latency_ms' => $data['latency_ms'] ?? null, + 'params_brief' => !empty($data['params_brief']) ? json_encode($data['params_brief'], JSON_UNESCAPED_UNICODE) : null, + 'changes' => !empty($data['changes']) ? json_encode($data['changes'], JSON_UNESCAPED_UNICODE) : null, + 'message' => $data['message'] ?? null, + 'created_at' => $now, + 'updated_at' => $now, + ]; + + Db::table('sys_op_log')->insert($row); + } catch (\Throwable $e) { + Log::error('audit_log consume error: '.$e->getMessage()); + return false; // 允许重试与失败处理 + } + return true; + } + + // 消费失败回调 + /* + $package = [ + 'id' => 1357277951, // 消息ID + 'time' => 1709170510, // 消息时间 + 'delay' => 0, // 延迟时间 + 'attempts' => 2, // 消费次数 + 'queue' => 'send-mail', // 队列名 + 'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容 + 'max_attempts' => 5, // 最大重试次数 + 'error' => '错误信息' // 错误信息 + ] + */ + public function onConsumeFailure(\Throwable $e, $package) + { + echo "consume failure\n"; + echo $e->getMessage() . "\n"; + // 无需反序列化 + var_export($package); + + Log::error('audit_log message failed after retries: '.json_encode($package, JSON_UNESCAPED_UNICODE)); + } +} \ No newline at end of file diff --git a/config/bootstrap.php b/config/bootstrap.php index 95d2e87..96699de 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -14,4 +14,7 @@ return [ support\bootstrap\Session::class, + + // 日志 + app\bootstrap\Audit::class, ]; diff --git a/config/middleware.php b/config/middleware.php index ae0ad6e..ec8d90a 100644 --- a/config/middleware.php +++ b/config/middleware.php @@ -17,6 +17,7 @@ return [ '' => [ \app\middleware\AccessControl::class, \app\middleware\BasicData::class, - \app\middleware\AuthCheck::class + \app\middleware\AuthCheck::class, + \app\middleware\Audit::class, ] ]; \ No newline at end of file From 0a8ebe169c6dfc70849bc1f4a8a7bd40b530460e Mon Sep 17 00:00:00 2001 From: mzl Date: Fri, 26 Sep 2025 15:58:02 +0800 Subject: [PATCH 03/21] Merge branch 'develop' --- config/process.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/process.php b/config/process.php index 2de2c20..8022ef3 100644 --- a/config/process.php +++ b/config/process.php @@ -66,4 +66,7 @@ return [ 'email' => [ 'handler' => app\process\Emails::class ], + 'checkweb' => [ + 'handler' => app\process\CheckWeb::class + ], ]; From 795a6ff1d11c1dd9b407cb435507023d62baaeb2 Mon Sep 17 00:00:00 2001 From: mzl Date: Fri, 26 Sep 2025 16:43:54 +0800 Subject: [PATCH 04/21] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E7=AB=99=E7=82=B9?= =?UTF-8?q?=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/process/CheckWeb.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/process/CheckWeb.php b/app/process/CheckWeb.php index c71a625..a7749fe 100644 --- a/app/process/CheckWeb.php +++ b/app/process/CheckWeb.php @@ -20,9 +20,9 @@ class CheckWeb public function check() { $result = $this->tocurl('https://magicbridgeauto.com'); - if (preg_match('/magicbridgeauto/', $result['body'])) { - flg("【magicbridgeauto】未检查到异常"); - } else { + if (!preg_match('/magicbridgeauto/', $result['body'])) { + // flg("【magicbridgeauto】未检查到异常"); + // } else { flg("【magicbridgeauto】疑似被篡改"); } } From 6128a084fc9fe4c99cf7ffdbfc625830b91526e8 Mon Sep 17 00:00:00 2001 From: mzl Date: Fri, 26 Sep 2025 16:44:50 +0800 Subject: [PATCH 05/21] Merge branch 'master' into develop # Conflicts: # app/process/CheckWeb.php --- app/process/CheckWeb.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/process/CheckWeb.php b/app/process/CheckWeb.php index c71a625..a7749fe 100644 --- a/app/process/CheckWeb.php +++ b/app/process/CheckWeb.php @@ -20,9 +20,9 @@ class CheckWeb public function check() { $result = $this->tocurl('https://magicbridgeauto.com'); - if (preg_match('/magicbridgeauto/', $result['body'])) { - flg("【magicbridgeauto】未检查到异常"); - } else { + if (!preg_match('/magicbridgeauto/', $result['body'])) { + // flg("【magicbridgeauto】未检查到异常"); + // } else { flg("【magicbridgeauto】疑似被篡改"); } } From 85a674eecadf0b3661f8a735522e531b00ef0047 Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Fri, 26 Sep 2025 16:57:10 +0800 Subject: [PATCH 06/21] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/common/controller/UploadController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/common/controller/UploadController.php b/app/common/controller/UploadController.php index 678034b..d90c84a 100644 --- a/app/common/controller/UploadController.php +++ b/app/common/controller/UploadController.php @@ -170,6 +170,8 @@ class UploadController 'original_name' => $file->getUploadName(), ]; + $partition = !empty($partition)?$partition:'other'; + $category = !empty($category)?$category:'other'; $recordResult = $uploadedFilesService->recordFileInfo($fileInfo, $request, $partition, $category); $data = []; $data['file_id'] = $recordResult['id']; From 12d25239eb6beaf8b7c417dd1315d3782635a991 Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Fri, 26 Sep 2025 17:44:48 +0800 Subject: [PATCH 07/21] =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=BA=BF=E7=B4=A2?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/crm/controller/CustomerController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/crm/controller/CustomerController.php b/app/crm/controller/CustomerController.php index e10222c..dc897ef 100644 --- a/app/crm/controller/CustomerController.php +++ b/app/crm/controller/CustomerController.php @@ -15,6 +15,7 @@ use app\crm\service\CustomerService; use app\crm\utils\Util; use config\constants\Constants; use support\Db; +use support\Log; use support\Request; class CustomerController extends BaseController @@ -358,6 +359,7 @@ class CustomerController extends BaseController ]; $dis = (new CustomerService())->distinct($distinct,$corpId); if($dis){ + Log::info('客户线索重复-'.$corpId,$dis); return $this->success($dis,'客户线索重复,已经业务跟进','distinct'); } $res = Customer::create($data); From 924f0c08f89b8aec54e1057eeeb111e75c756c33 Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Fri, 26 Sep 2025 17:55:07 +0800 Subject: [PATCH 08/21] =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=BA=BF=E7=B4=A2?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/crm/controller/CustomerController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/crm/controller/CustomerController.php b/app/crm/controller/CustomerController.php index e10222c..25b186e 100644 --- a/app/crm/controller/CustomerController.php +++ b/app/crm/controller/CustomerController.php @@ -15,6 +15,7 @@ use app\crm\service\CustomerService; use app\crm\utils\Util; use config\constants\Constants; use support\Db; +use support\Log; use support\Request; class CustomerController extends BaseController @@ -358,6 +359,7 @@ class CustomerController extends BaseController ]; $dis = (new CustomerService())->distinct($distinct,$corpId); if($dis){ + Log::info('客户线索重复-'.$corpId,['user_id'=>$data['user_id'],'distinct'=>$distinct]); return $this->success($dis,'客户线索重复,已经业务跟进','distinct'); } $res = Customer::create($data); From a86a4d7640db2246b6b2606a1910e7ae7f9366a7 Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Fri, 26 Sep 2025 17:55:49 +0800 Subject: [PATCH 09/21] =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=BA=BF=E7=B4=A2?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/crm/controller/CustomerController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/crm/controller/CustomerController.php b/app/crm/controller/CustomerController.php index dc897ef..25b186e 100644 --- a/app/crm/controller/CustomerController.php +++ b/app/crm/controller/CustomerController.php @@ -359,7 +359,7 @@ class CustomerController extends BaseController ]; $dis = (new CustomerService())->distinct($distinct,$corpId); if($dis){ - Log::info('客户线索重复-'.$corpId,$dis); + Log::info('客户线索重复-'.$corpId,['user_id'=>$data['user_id'],'distinct'=>$distinct]); return $this->success($dis,'客户线索重复,已经业务跟进','distinct'); } $res = Customer::create($data); From b1a32429ba22605cb185a653d15f2dc3561e8b0c Mon Sep 17 00:00:00 2001 From: mzl Date: Tue, 30 Sep 2025 09:12:27 +0800 Subject: [PATCH 10/21] =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/process/CheckWeb.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/process/CheckWeb.php b/app/process/CheckWeb.php index a7749fe..8baf5b8 100644 --- a/app/process/CheckWeb.php +++ b/app/process/CheckWeb.php @@ -11,7 +11,11 @@ class CheckWeb { if (getenv('ENVIRONMENT') === 'master') { new Crontab('0 */10 * * * *', function(){ - $this->check(); + $result = $this->tocurl('https://magicbridgeauto.com'); + if (!preg_match('/magicbridgeauto/', $result['body'])) { + flg("【magicbridgeauto】疑似被篡改"); + } + return true; }); } } @@ -25,6 +29,7 @@ class CheckWeb // } else { flg("【magicbridgeauto】疑似被篡改"); } + return true; } From f3a95b7a6f377fb2ccc9ea3e9600e6ec834350e6 Mon Sep 17 00:00:00 2001 From: mzl Date: Tue, 30 Sep 2025 09:13:57 +0800 Subject: [PATCH 11/21] Merge branch 'master' into develop # Conflicts: # app/process/CheckWeb.php --- app/common/controller/UploadController.php | 2 ++ app/process/CheckWeb.php | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/common/controller/UploadController.php b/app/common/controller/UploadController.php index 678034b..d90c84a 100644 --- a/app/common/controller/UploadController.php +++ b/app/common/controller/UploadController.php @@ -170,6 +170,8 @@ class UploadController 'original_name' => $file->getUploadName(), ]; + $partition = !empty($partition)?$partition:'other'; + $category = !empty($category)?$category:'other'; $recordResult = $uploadedFilesService->recordFileInfo($fileInfo, $request, $partition, $category); $data = []; $data['file_id'] = $recordResult['id']; diff --git a/app/process/CheckWeb.php b/app/process/CheckWeb.php index a7749fe..8baf5b8 100644 --- a/app/process/CheckWeb.php +++ b/app/process/CheckWeb.php @@ -11,7 +11,11 @@ class CheckWeb { if (getenv('ENVIRONMENT') === 'master') { new Crontab('0 */10 * * * *', function(){ - $this->check(); + $result = $this->tocurl('https://magicbridgeauto.com'); + if (!preg_match('/magicbridgeauto/', $result['body'])) { + flg("【magicbridgeauto】疑似被篡改"); + } + return true; }); } } @@ -25,6 +29,7 @@ class CheckWeb // } else { flg("【magicbridgeauto】疑似被篡改"); } + return true; } From 4fe008354de8ec2e52853edcabc1e9559c6a3064 Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Fri, 10 Oct 2025 16:48:33 +0800 Subject: [PATCH 12/21] =?UTF-8?q?=E8=BD=A6=E8=BE=86=E5=93=81=E7=89=8C?= =?UTF-8?q?=E4=B8=8E=E6=8A=93=E5=8F=96=E8=BD=A6=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/service/BrandSeriesService.php | 144 +++++++++++++++- app/admin/service/BrandService.php | 80 ++++++++- app/common/controller/BrandController.php | 90 ++++++++++ .../controller/BrandSeriesController.php | 85 ++++++++++ app/common/controller/SysPortController.php | 53 ++++++ app/common/service/CrawlService.php | 76 +++++++++ composer.json | 3 +- composer.lock | 157 +++++++++++++++++- config/route.php | 32 ++++ 9 files changed, 711 insertions(+), 9 deletions(-) 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/service/CrawlService.php diff --git a/app/admin/service/BrandSeriesService.php b/app/admin/service/BrandSeriesService.php index bc9d11b..1e9feaf 100644 --- a/app/admin/service/BrandSeriesService.php +++ b/app/admin/service/BrandSeriesService.php @@ -3,10 +3,19 @@ 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 support\Log; use support\Request; +use Webman\RedisQueue\Redis; class BrandSeriesService { @@ -28,7 +37,7 @@ class BrandSeriesService */ public function getValidator() { - return new $this->validator; + return new $this->validator; } /** @@ -50,13 +59,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 +101,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 +132,131 @@ class BrandSeriesService $data['series_code'] = $this->generateUniqueModificationCode($brand_id); return $data; } + + /** + * 抓取车型数据 + * @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; + +// // 将抓取任务加入队列 +// Redis::send('series-crawl', [ +// 'brand_series_id' => $series +// ]); +// +// return true; + } + + /** + * @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/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/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/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/route.php b/config/route.php index 4049182..fe42a7a 100644 --- a/config/route.php +++ b/config/route.php @@ -87,6 +87,38 @@ 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']); + }); + }); + }); }); // 后台管理 From 5d764beeb11ed72843b1f03c5c445f8d83dfcdc6 Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Fri, 10 Oct 2025 16:59:17 +0800 Subject: [PATCH 13/21] =?UTF-8?q?=E8=B7=AF=E7=94=B1=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/route.php | 1 - 1 file changed, 1 deletion(-) diff --git a/config/route.php b/config/route.php index fe42a7a..9ce0ee9 100644 --- a/config/route.php +++ b/config/route.php @@ -118,7 +118,6 @@ Route::group('/common', function () { Route::any('/destroy', [app\common\controller\SysPortController::class, 'destroy']); }); }); - }); }); // 后台管理 From cbf41a604a1179815c806b5112a222b0501a294a Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Sat, 11 Oct 2025 09:30:16 +0800 Subject: [PATCH 14/21] =?UTF-8?q?=E8=8E=B7=E5=8F=96=E7=B3=BB=E5=88=97?= =?UTF-8?q?=E4=B8=8B=E8=BD=A6=E5=9E=8B=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/service/BrandSeriesService.php | 121 ++++++++++++++++++++--- 1 file changed, 110 insertions(+), 11 deletions(-) diff --git a/app/admin/service/BrandSeriesService.php b/app/admin/service/BrandSeriesService.php index 1e9feaf..9ff6ffd 100644 --- a/app/admin/service/BrandSeriesService.php +++ b/app/admin/service/BrandSeriesService.php @@ -13,6 +13,7 @@ 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; @@ -134,12 +135,117 @@ class BrandSeriesService } /** - * 抓取车型数据 + * 抓取车型数据(通过接口) * @param Request $request * @return array * @throws \Exception */ - public function crawlModel(Request $request) + 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); + $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'])){ + 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] = [ + 'model_name' => $model["info"]['year'] . '款 ' .$model["info"]['car_name'], + 'brand_name' => $model["info"]['brand_name'], + 'year' => $model["info"]['year'], + 'msrp' => $official_price, + ]; + } + return $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'); @@ -167,14 +273,7 @@ class BrandSeriesService } return $models; - -// // 将抓取任务加入队列 -// Redis::send('series-crawl', [ -// 'brand_series_id' => $series -// ]); -// -// return true; - } + }*/ /** * @param Request $request @@ -203,7 +302,7 @@ class BrandSeriesService foreach ($models as $item) { $modelData = [ - 'brand_code' => $brand->brand_code ?? '', + 'brand_code' => $brand->brand_code, 'brand_name' => $brand->brand_name, 'series_code' => $series->series_code ?? '', 'series_name' => $series->series_name ?? '', From e3572cde9e186828ca27f65cf353f16b2061a3a3 Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Sat, 11 Oct 2025 09:36:56 +0800 Subject: [PATCH 15/21] =?UTF-8?q?=E6=8A=93=E5=8F=96=E5=90=8E=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E8=BD=A6=E8=BE=86=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/service/BrandSeriesService.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/admin/service/BrandSeriesService.php b/app/admin/service/BrandSeriesService.php index 9ff6ffd..cc946c8 100644 --- a/app/admin/service/BrandSeriesService.php +++ b/app/admin/service/BrandSeriesService.php @@ -198,13 +198,15 @@ class BrandSeriesService $official_price = $model["info"]['official_price'] * 10000; } $models[$key] = [ - 'model_name' => $model["info"]['year'] . '款 ' .$model["info"]['car_name'], + '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 $models; + return array_values($models); } catch (\Exception $e) { throw new \Exception($e->getMessage()); } From 788d962044fb5cc990f1d92a44a54f9088f092e6 Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Sat, 11 Oct 2025 10:18:30 +0800 Subject: [PATCH 16/21] =?UTF-8?q?=E8=BD=A6=E5=9E=8B=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/service/BrandSeriesService.php | 4 ++-- app/common/service/VehVehicleSkuService.php | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/admin/service/BrandSeriesService.php b/app/admin/service/BrandSeriesService.php index cc946c8..7bf9b75 100644 --- a/app/admin/service/BrandSeriesService.php +++ b/app/admin/service/BrandSeriesService.php @@ -306,8 +306,8 @@ class BrandSeriesService $modelData = [ 'brand_code' => $brand->brand_code, 'brand_name' => $brand->brand_name, - 'series_code' => $series->series_code ?? '', - 'series_name' => $series->series_name ?? '', + 'series_code' => $series->series_code, + 'series_name' => $series->series_name, 'model_name' => $item['title'], 'model_year' => $item['year'], 'msrp' => $item['msrp'], diff --git a/app/common/service/VehVehicleSkuService.php b/app/common/service/VehVehicleSkuService.php index e494122..d8c6e4a 100644 --- a/app/common/service/VehVehicleSkuService.php +++ b/app/common/service/VehVehicleSkuService.php @@ -5,6 +5,8 @@ namespace app\common\service; use app\common\logic\Fingerprint; use app\common\logic\ModelNaming; use app\common\logic\SkuCodeBuilder; +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; @@ -111,6 +113,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']; From c33c57f3caa15111a75ba22eb7b6cabcccf36450 Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Sat, 11 Oct 2025 10:50:59 +0800 Subject: [PATCH 17/21] =?UTF-8?q?=E8=BD=A6=E5=9E=8B=E4=B8=8B=E6=8B=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/VehVehicleSkuController.php | 18 ++++++++++++++++++ app/common/logic/VehVehicleSkuLogic.php | 5 +++++ app/common/service/VehVehicleSkuService.php | 16 ++++++++++++++++ config/route.php | 1 + 4 files changed, 40 insertions(+) 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/VehVehicleSkuLogic.php b/app/common/logic/VehVehicleSkuLogic.php index 505efde..054204a 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', 'msrp']); + } } \ No newline at end of file diff --git a/app/common/service/VehVehicleSkuService.php b/app/common/service/VehVehicleSkuService.php index d8c6e4a..4e54818 100644 --- a/app/common/service/VehVehicleSkuService.php +++ b/app/common/service/VehVehicleSkuService.php @@ -5,6 +5,7 @@ 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; @@ -27,6 +28,13 @@ class VehVehicleSkuService */ protected $validator = VehVehicleSkuValidate::class; + protected $logic; + + public function __construct() + { + $this->logic = (new VehVehicleSkuLogic()); + } + /** * 获取验证器 */ @@ -188,4 +196,12 @@ class VehVehicleSkuService return $res; } + + /** + * 获取车型list + */ + public function getList($data) + { + return $this->logic->getList($data); + } } \ No newline at end of file diff --git a/config/route.php b/config/route.php index 9ce0ee9..18bc3b4 100644 --- a/config/route.php +++ b/config/route.php @@ -499,6 +499,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']); }); }); From 84bd19efffa328c8309b111c7f5cc20936b9c493 Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Sat, 11 Oct 2025 13:27:03 +0800 Subject: [PATCH 18/21] =?UTF-8?q?=E8=BD=A6=E5=9E=8B=E6=8A=93=E5=8F=96?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/service/BrandSeriesService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/admin/service/BrandSeriesService.php b/app/admin/service/BrandSeriesService.php index 7bf9b75..2e7f04c 100644 --- a/app/admin/service/BrandSeriesService.php +++ b/app/admin/service/BrandSeriesService.php @@ -187,7 +187,7 @@ class BrandSeriesService } } foreach ($models as $key => $model) { - if(!isset($model["info"]['car_name'])){ + if(!isset($model["info"]['car_name']) || $model['type'] != "1115"){ unset($models[$key]); continue; } From d337fa88a975ae6a30ace0e13017a66a20c605d8 Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Sat, 11 Oct 2025 15:54:04 +0800 Subject: [PATCH 19/21] =?UTF-8?q?=E8=BD=A6=E5=9E=8B=E4=B8=8B=E6=8B=89?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=20=E5=B9=B4=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/common/logic/VehVehicleSkuLogic.php | 2 +- app/common/service/VehVehicleSkuService.php | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/common/logic/VehVehicleSkuLogic.php b/app/common/logic/VehVehicleSkuLogic.php index 054204a..43f7172 100644 --- a/app/common/logic/VehVehicleSkuLogic.php +++ b/app/common/logic/VehVehicleSkuLogic.php @@ -55,6 +55,6 @@ class VehVehicleSkuLogic 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', 'msrp']); + 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/service/VehVehicleSkuService.php b/app/common/service/VehVehicleSkuService.php index 4e54818..6cd5230 100644 --- a/app/common/service/VehVehicleSkuService.php +++ b/app/common/service/VehVehicleSkuService.php @@ -157,6 +157,9 @@ class VehVehicleSkuService if (empty($data['model_name'])) { $data['model_name'] = ModelNaming::buildModelName($data); } + if (empty($data['engine_disp_l'])) { + $data['engine_disp_l'] = 0; + } $data['sku_code'] = SkuCodeBuilder::build($data); $data['corp_id'] = 0; $data['created_by'] = $request->user->uid; From 44973812203630d9da899da87348e618a38029e7 Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Sat, 11 Oct 2025 16:06:42 +0800 Subject: [PATCH 20/21] =?UTF-8?q?=E5=BF=85=E8=A6=81=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E9=BB=98=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/common/service/VehVehicleSkuService.php | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/common/service/VehVehicleSkuService.php b/app/common/service/VehVehicleSkuService.php index 6cd5230..a81adce 100644 --- a/app/common/service/VehVehicleSkuService.php +++ b/app/common/service/VehVehicleSkuService.php @@ -160,6 +160,30 @@ class VehVehicleSkuService 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; From b63a79697c23a0b18f4c65d540784add8cd2ec3a Mon Sep 17 00:00:00 2001 From: "WIN-C27C5MSVTPO\\16961" <1696136552@qq.com> Date: Sat, 11 Oct 2025 16:47:07 +0800 Subject: [PATCH 21/21] =?UTF-8?q?=E6=B8=AF=E5=8F=A3=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/route.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/config/route.php b/config/route.php index 18bc3b4..66c614f 100644 --- a/config/route.php +++ b/config/route.php @@ -108,15 +108,15 @@ Route::group('/common', function () { 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']); - }); + // 港口 + 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']); }); });