Compare commits
335 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43ea23307d | |||
| 4ec72f7772 | |||
| 51a553e1a4 | |||
| 46666d1c2f | |||
| 5a94a74590 | |||
| 88338b60fa | |||
| fda46cd187 | |||
| f16feca4f4 | |||
| 6e458e3751 | |||
| f2451a48bb | |||
| c88d2f20bc | |||
| a7bc73f0d3 | |||
| 4787e21491 | |||
| d5a1b11b35 | |||
| 530a8123ba | |||
| acdabea76e | |||
| f33f969166 | |||
| 499611c700 | |||
| 114b1fbf92 | |||
| a1c2b36d92 | |||
| f7fa768da1 | |||
| 51344ac1fc | |||
| 7ac5bcd516 | |||
| 67069576b7 | |||
| 1951e2333c | |||
| 19eab96bdc | |||
| ae9dd498af | |||
| 766b717a5a | |||
| f2524dec22 | |||
| 89a824f1b8 | |||
| 5a3a1cf0de | |||
| 9210dcb312 | |||
| 0e88fdf1d1 | |||
| 6c9adce32a | |||
| 73ce99edfb | |||
| 3168885701 | |||
| 30e04b3d4c | |||
| ddbec81d04 | |||
| 63605accc0 | |||
| 9f78530185 | |||
| 1c26828569 | |||
| ef2bd1df55 | |||
| 1d88ef932a | |||
| 5120f4bf4f | |||
| 1251f78c6b | |||
| f44c8611d1 | |||
| e829e406e1 | |||
| f28da4c4b9 | |||
| 407e573c0b | |||
| c2e2a20e35 | |||
| 8d83309daf | |||
| a8f1af9995 | |||
| fb8f87dd5f | |||
| d59c390674 | |||
| 144fa72075 | |||
| b4445abc3a | |||
| cf7d8a9f5b | |||
| 976945378e | |||
| d02555df0a | |||
| a9aabfa454 | |||
| 0067a7508f | |||
| cd692594e4 | |||
| 6028ad3b6a | |||
| 7098f30af2 | |||
| 119e725b17 | |||
| 28287e7a7c | |||
| 3e16776d65 | |||
| 244b874751 | |||
| e4fb1fda2c | |||
| 9b55bcce79 | |||
| 466519378e | |||
| b721860d4a | |||
| 6a0fbd01fd | |||
| d3d55df4c9 | |||
| 39e673ea1a | |||
| 41d968b369 | |||
| 01f49f35b2 | |||
| 20857453d2 | |||
| f57b0ca1f0 | |||
| 452182f0b2 | |||
| 90a431015b | |||
| 6469e05176 | |||
| 698f532ced | |||
| 3da11b4b37 | |||
| af90824edd | |||
| f6c8271729 | |||
| ad5c053a5e | |||
| 40bc3a894d | |||
| c72b128872 | |||
| fde98ea5b9 | |||
| 5f605f5a63 | |||
| 4894b25722 | |||
| 92776b39d4 | |||
| 537b22c6fb | |||
| 1dfc183cac | |||
| 25b79d6f63 | |||
| 359a8f403c | |||
| 3d33a502d9 | |||
| eeab4934cd | |||
| 74ca1a5d8c | |||
| 488d510ad0 | |||
| d719ee5ea2 | |||
| 04a3c85bc1 | |||
| d3ed9c05c6 | |||
| 8105200154 | |||
| 070cb87d17 | |||
| 032b19a4ce | |||
| 320211cd21 | |||
| a2218dc517 | |||
| 7b1837ed20 | |||
| 828c8f1d60 | |||
| b831775490 | |||
| d58c54da76 | |||
| cdb889137f | |||
| e3b38ee117 | |||
| 4464c86788 | |||
| 750c2fc642 | |||
| a521cfcaf4 | |||
| 385f380914 | |||
| e84aba0a49 | |||
| f67f9ddef9 | |||
| a39c18b714 | |||
| 44dc6c06ee | |||
| 757689573b | |||
| be44d1dda2 | |||
| 28e5afe62a | |||
| e922e78ae2 | |||
| 7bf2249ce6 | |||
| f0a92cafda | |||
| 9686e52484 | |||
| c896bef9a6 | |||
| a8cab8c51c | |||
| e4f0d107ec | |||
| a16429ff3a | |||
| 59750d52f6 | |||
| bce47e81f8 | |||
| 6496a40679 | |||
| ab3bffe7cb | |||
| a8d1309349 | |||
| 80d0196c3d | |||
| 278dda4f7a | |||
| e408f7e20e | |||
| 7cafb61797 | |||
| 5a7a6a45d4 | |||
| 5841b342de | |||
| 63011a0351 | |||
| 2b529a27c8 | |||
| cab15c4fab | |||
| 139acd9e70 | |||
| c38a53f053 | |||
| 07a40bf688 | |||
| c2e402722d | |||
| de9484f570 | |||
| ca757e67dd | |||
| 725710abcb | |||
| 399eaa2b5c | |||
| 253616b2bb | |||
| beabfd96fb | |||
| cc93979e9a | |||
| f336d1c4af | |||
| 35836edbfa | |||
| 32a7752f8b | |||
| f182684378 | |||
| d53f18181a | |||
| 75e31a7e9f | |||
| c4a2b39edd | |||
| 45169572b5 | |||
| 631d433b7e | |||
| 9fbe094a30 | |||
| 1ba1d5763f | |||
| 5086344742 | |||
| 63cca12a8d | |||
| eb4813b77b | |||
| f664653eb9 | |||
| 00bd5ada53 | |||
| 2c295cd631 | |||
| 134505be8c | |||
| 79d19a53e6 | |||
| 1d9fbb9032 | |||
| 76bd46a0d7 | |||
| 20188521fd | |||
| 1d4224bf4a | |||
| 8b8336437a | |||
| c157b3ee12 | |||
| 2d7456b74c | |||
| 8740918785 | |||
| d515bc07fe | |||
| 39820844f7 | |||
| 6ab4505e17 | |||
| 663d29dc61 | |||
| 862a87d66f | |||
| 32452dc29a | |||
| f19ec4edc4 | |||
| 7e90b05dde | |||
| 29dd6a2954 | |||
| f73bad8082 | |||
| be4dcb4dd4 | |||
| 1eccabb806 | |||
| 013fc83362 | |||
| 42bf32a9a5 | |||
| 2f8f7dccf9 | |||
| 49e7709ae3 | |||
| 5b77b8ed83 | |||
| a98769fb4c | |||
| 56ad551707 | |||
| 2711ed8f6a | |||
| f1d5fae122 | |||
| 4f965f8b5f | |||
| 4dbd72acce | |||
| 0c789b7a1a | |||
| 8674063e6e | |||
| 1094bc406f | |||
| 596313ed5a | |||
| 74ac8941ea | |||
| 6493bdebb1 | |||
| 07a3d66079 | |||
| 18eb3e7c5d | |||
| 262add20f8 | |||
| 139d3509f4 | |||
| f7faf79809 | |||
| 29ba35f9b6 | |||
| 0e38f0f9bb | |||
| 43fe263df1 | |||
| 58a38d1463 | |||
| 821f455e47 | |||
| 24805257c7 | |||
| b4ce4fe672 | |||
| 206eff87e2 | |||
| 2e85b46a3d | |||
| 72fa638cf5 | |||
| 5f25ac9895 | |||
| ad67a88d0f | |||
| 4c3f8c9cf7 | |||
| 3f8dbf97e4 | |||
| e9636513ec | |||
| 9f167ad526 | |||
| d737281283 | |||
| 695c398962 | |||
| 0110e393ce | |||
| ea0d5b14bc | |||
| a3819bcba7 | |||
| 3d6aa5f365 | |||
| 79d0ae2cbd | |||
| 659f6d4866 | |||
| 1b4914f7b7 | |||
| c8cdc5ba00 | |||
| 94e9d6eb7e | |||
| 3d1a4ceeb5 | |||
| 7db7e652de | |||
| 9fbe77a3a9 | |||
| 4c63c1b669 | |||
| f64324fba5 | |||
| 499b0894b7 | |||
| 88ebef4fc3 | |||
| 15df6ad9a3 | |||
| 02fe911a44 | |||
| 5facb1abba | |||
| 077edc5fd6 | |||
| ef0c236130 | |||
| 8c3a445a39 | |||
| a7d623656d | |||
| f004d99802 | |||
| ea3d511c4d | |||
| 25f8017b3c | |||
| 59f474a037 | |||
| 672258e594 | |||
| 9f944ae92c | |||
| c2d291bed4 | |||
| f3b1c9bae4 | |||
| 346d67c084 | |||
| eacdb93be2 | |||
| cf45acbf0f | |||
| b235747a9a | |||
| b440c03241 | |||
| 2b2e821779 | |||
| 07c0b2a2f3 | |||
| 6baee549c0 | |||
| 8a1b2f2369 | |||
| 2127473290 | |||
| 77dad7daed | |||
| 38effed458 | |||
| 1bd2eee8db | |||
| 36c7d19319 | |||
| 19cb169abd | |||
| a85fa8f072 | |||
| 83d76b16b4 | |||
| 2f9f483661 | |||
| 7fd28461fe | |||
| 5cc67b78f1 | |||
| 770664e70f | |||
| 6bd4ed9ec3 | |||
| 211fe575b8 | |||
| 18786591b1 | |||
| 1c3585b079 | |||
| 8db33a40a4 | |||
| c1ab40031d | |||
| 2c8efc18f9 | |||
| 03ab55bd63 | |||
| 4326bcd68b | |||
| 7036e3b849 | |||
| 0f505d5c56 | |||
| 33efdc8534 | |||
| 6644cc1c58 | |||
| 797256e707 | |||
| 448d151106 | |||
| 7486803468 | |||
| 4c1ac68a2b | |||
| 1cb5a1d5b2 | |||
| 55850aef81 | |||
| 13ab4b7868 | |||
| 67ef59f9fd | |||
| 07f9a4306f | |||
| f17646238e | |||
| 03d010ec88 | |||
| 13d24d041d | |||
| 4c94392ef6 | |||
| 5c6978c803 | |||
| 73ed4a413c | |||
| d05106f9df | |||
| 90cce108b0 | |||
| a3a6cfefc0 | |||
| 40dfd99f4e | |||
| ae6cd4ec78 | |||
| ec2502cd58 | |||
| e0b11718a7 | |||
| 82e4435a2c | |||
| 435d3ce677 | |||
| abc1142e88 | |||
| 54f83679ea | |||
| db10ece6b7 | |||
| 870bca5e57 | |||
| 1d6caad7b0 | |||
| e6d3cc3f04 | |||
| 4717bb01ca | |||
| 36d1d333b1 |
+1
-2
@@ -13,5 +13,4 @@
|
||||
/bootstrap.php
|
||||
/composer.lock
|
||||
.env_copy
|
||||
/public/excel
|
||||
/docs/*.sql
|
||||
/public/excel
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
namespace app\admin\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\model\Corp;
|
||||
use app\common\model\User;
|
||||
use app\common\service\OssService;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\InvItemService;
|
||||
use support\Request;
|
||||
|
||||
class InvItemController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InvItemService();
|
||||
}
|
||||
|
||||
public function preset(Request $request){
|
||||
try {
|
||||
$res = $this->service->getList();
|
||||
$result = [];
|
||||
foreach ($res as $value) {
|
||||
$value['company_name'] = Corp::where('id', $value['company_id'])->value('name');
|
||||
$value['manage_user_name'] = User::where('id', $value['manage_user_id'])->value('name');
|
||||
$result[] = $value;
|
||||
}
|
||||
return $this->success($result, '获取物品列表');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
protected function indexFormat($data)
|
||||
{
|
||||
if (isset($data['created_at']))
|
||||
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
if (isset($data['updated_at']))
|
||||
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
|
||||
if (isset($data['company_id']))
|
||||
$data['company_name'] = Corp::where('id', $data['company_id'])->value('name');
|
||||
if (isset($data['manage_user_id']))
|
||||
$data['manage_user_name'] = User::where('id', $data['manage_user_id'])->value('name');
|
||||
if (!empty($data['qr_code']))
|
||||
$data['qr_code_pull'] = (new OssService)->generatePrivateUrl($data['qr_code']);;
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成物品二维码
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function generateQrCode(Request $request)
|
||||
{
|
||||
try {
|
||||
$id = $request->post('id');
|
||||
$num = $request->post('num');
|
||||
if (!$id) {
|
||||
return $this->error('物品ID不能为空');
|
||||
}
|
||||
|
||||
$item = \app\common\model\InvItem::find($id);
|
||||
if (!$item) {
|
||||
return $this->error('物品不存在');
|
||||
}
|
||||
|
||||
// 获取应用访问地址
|
||||
$appUrl = 'https://'.$request->host();
|
||||
|
||||
$qrCodeService = new \app\common\service\QrCodeService();
|
||||
$content = $qrCodeService->generateItemQrCode($id, $appUrl);
|
||||
$fileName = 'qr_item_' . $id . '_' . time() . '.png';
|
||||
|
||||
$result = $qrCodeService->generateAndUploadQrCode($id, $content, $fileName);
|
||||
|
||||
// 更新物品记录
|
||||
$item->qr_code = $result['path'];
|
||||
//$item->qr_code_num = !empty($num)?$num:0;
|
||||
$item->qr_code_url = $result['url'];
|
||||
$item->save();
|
||||
|
||||
return $this->success([
|
||||
'qr_code_url' => $result['url'],
|
||||
'content' => $content,
|
||||
], '二维码生成成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除物品二维码
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function deleteQrCode(Request $request)
|
||||
{
|
||||
try {
|
||||
$id = $request->post('id');
|
||||
if (!$id) {
|
||||
return $this->error('物品ID不能为空');
|
||||
}
|
||||
|
||||
$item = \app\common\model\InvItem::find($id);
|
||||
if (!$item) {
|
||||
return $this->error('物品不存在');
|
||||
}
|
||||
|
||||
// 删除 OSS 文件
|
||||
if ($item->qr_code) {
|
||||
$qrCodeService = new \app\common\service\QrCodeService();
|
||||
$qrCodeService->deleteQrCode($item->qr_code);
|
||||
}
|
||||
|
||||
// 清空物品记录中的二维码字段
|
||||
$item->qr_code = '';
|
||||
//$item->qr_code_num = 0;
|
||||
$item->qr_code_url = '';
|
||||
$item->save();
|
||||
|
||||
return $this->success([], '二维码删除成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
namespace app\admin\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\model\InvRequisitionApprove;
|
||||
use app\common\model\InvRequisitionAttachment;
|
||||
use app\common\model\InvRequisitionItem;
|
||||
use app\common\service\OssService;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\admin\service\InvRequisitionService;
|
||||
use support\Request;
|
||||
|
||||
class InvRequisitionController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InvRequisitionService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核
|
||||
*
|
||||
* @param Request $request
|
||||
* @return \support\Response
|
||||
*/
|
||||
public function approve(Request $request)
|
||||
{
|
||||
try {
|
||||
$data = $this->service->serviceApprove($request);
|
||||
return $this->success($data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return mixed 返回分页数据及总数
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
try {
|
||||
$applicant_id = $request->get('applicant_id');
|
||||
$uid = $request->user->uid;
|
||||
$raw = $this->service->serviceList($request);
|
||||
$data = $this->formatindex($raw,$applicant_id,$uid);
|
||||
// $data = $this->transformData($raw, method_exists($this, 'indexFormat')
|
||||
// ? [$this, 'indexFormat']
|
||||
// : null);
|
||||
return $this->success($data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function formatindex($p,$applicant_id,$uid){
|
||||
$records = $p->getCollection()
|
||||
->map(fn($item) => $this->indexFormat($item, $applicant_id,$uid))
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'records' => $records,
|
||||
'total' => $p->total(),
|
||||
'current' => $p->currentPage(),
|
||||
'size' => $p->perPage(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
protected function indexFormat($data, $applicant_id, $uid)
|
||||
{
|
||||
if (isset($data['id'])){
|
||||
//$data['approve'] = InvRequisitionApprove::where('requisition_id', $data['id'])->get();
|
||||
$data = $data->toArray();
|
||||
}
|
||||
if (isset($data['created_at'])){
|
||||
$data['created_at'] = is_int($data['created_at'])?$data['created_at']:strtotime($data['created_at']);
|
||||
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
}
|
||||
if (isset($data['updated_at'])){
|
||||
$data['updated_at'] = is_int($data['updated_at'])?$data['updated_at']:strtotime($data['updated_at']);
|
||||
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
|
||||
}
|
||||
if (isset($data['id'])){
|
||||
$data['item'] = InvRequisitionItem::where('requisition_id', $data['id'])->get();
|
||||
}
|
||||
|
||||
if($applicant_id != 'self' && isset($data['id'])){ //不是查询自己数据时查询是否已审核
|
||||
$approve = InvRequisitionApprove::where('requisition_id', $data['id'])
|
||||
->where('approver_id', $uid)
|
||||
->first();
|
||||
$data['action'] = $approve['action']??3;//查询人审核状态:0待审核 1通过 2驳回 3非审核人
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
protected function showFormat($data)
|
||||
{
|
||||
if (isset($data['id'])){
|
||||
$data['approve'] = InvRequisitionApprove::where('requisition_id', $data['id'])->orderBy('id')->get()->toArray();
|
||||
|
||||
$approve_id = 0;
|
||||
foreach ($data['approve'] as $key => $value) {
|
||||
if (isset($value['created_at'])){
|
||||
$value['created_at'] = is_int($value['created_at'])?$value['created_at']:strtotime($value['created_at']);
|
||||
$data['approve'][$key]['created_at'] = date('Y-m-d H:i:s', $value['created_at']);
|
||||
}
|
||||
if (isset($value['updated_at'])){
|
||||
$value['updated_at'] = is_int($value['updated_at'])?$value['updated_at']:strtotime($value['updated_at']);
|
||||
$data['approve'][$key]['updated_at'] = date('Y-m-d H:i:s', $value['updated_at']);
|
||||
}
|
||||
$data['approve'][$key]['action_at'] = date('Y-m-d H:i:s', $data['approve'][$key]['action_at']);
|
||||
$approve_id = $value['id'];
|
||||
}
|
||||
$data['approve_id'] = $approve_id;
|
||||
}
|
||||
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['id'])){
|
||||
$data['item'] = InvRequisitionItem::where('requisition_id', $data['id'])->get();
|
||||
$data['attachment'] = InvRequisitionAttachment::where('requisition_id', $data['id'])->get();
|
||||
$oss = new OssService();
|
||||
foreach ($data['attachment'] as $key => $value) {
|
||||
$data['attachment'][$key]['file_path_pull'] = $oss->generatePrivateUrl($value['file_path']);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员代领
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function proxyClaim(Request $request)
|
||||
{
|
||||
try {
|
||||
$data = $this->service->proxyClaim($request, $request->post());
|
||||
return $this->success($data, '代领成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码/代领记录查询
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function qrClaims(Request $request)
|
||||
{
|
||||
try {
|
||||
$data = $this->service->qrClaimsList($request);
|
||||
return $this->success($data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
namespace app\admin\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\InvRequisitionReturnService;
|
||||
use support\Request;
|
||||
|
||||
class InvRequisitionReturnController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InvRequisitionReturnService();
|
||||
}
|
||||
|
||||
// /**
|
||||
// * 列表页数据格式化
|
||||
// *
|
||||
// * @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;
|
||||
// }
|
||||
|
||||
public function returnConfirm(Request $request){
|
||||
try {
|
||||
// 处理申请逻辑
|
||||
$result = $this->service->serviceReturnConfirm($request);
|
||||
|
||||
return $this->success($result, '申请提交成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace app\admin\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\service\InvStockLogService;
|
||||
use support\Request;
|
||||
|
||||
class InvStockLogController extends BaseController
|
||||
{
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InvStockLogService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 库存变化记录列表
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function list(Request $request)
|
||||
{
|
||||
try {
|
||||
$data = $this->service->getList($request->get());
|
||||
return $this->success($data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,10 +136,6 @@ class StockOrdersController extends BaseController
|
||||
];
|
||||
}
|
||||
unset($item['options']);
|
||||
// 附件 url 响应期重签(历史签名URL会过期)
|
||||
if (!empty($item['attachment']) && is_array($item['attachment'])) {
|
||||
$item['attachment'] = \app\common\service\OssService::resignAttachmentUrls($item['attachment']);
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\service;
|
||||
|
||||
use app\common\validate\InvRequisitionValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\InvRequisition;
|
||||
use support\Request;
|
||||
|
||||
class InvRequisitionService
|
||||
{
|
||||
|
||||
use CrudServiceTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisition::class;
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected $validator = InvRequisitionValidate::class;
|
||||
|
||||
/**
|
||||
* 获取验证器
|
||||
*/
|
||||
public function getValidator()
|
||||
{
|
||||
return new $this->validator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 CrudUtil 查询配置
|
||||
*/
|
||||
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
|
||||
{
|
||||
$p = $request->all();
|
||||
|
||||
$config = [];
|
||||
$config['model'] = $this->modelClass;
|
||||
switch ($scene) {
|
||||
case 'default':
|
||||
//$config['where']['status'] = 1;
|
||||
$config['order']['id'] = 'desc';
|
||||
break;
|
||||
case 'index':
|
||||
if(isset($p['status']) && $p['status']){
|
||||
$config['where']['status'] = $p['status'];
|
||||
}
|
||||
if (isset($p['applicant_id']) && $p['applicant_id'] == 'self') { //自己的领用申请
|
||||
$config['where']['applicant_id'] = $request->user->uid;
|
||||
} elseif (isset($p['applicant_id']) && $p['applicant_id']) { //指定人员的申请
|
||||
$config['where']['applicant_id'] = $p['applicant_id'];
|
||||
} else { //审批人对应的记录
|
||||
$config['where']['approval.approver_id'] = $request->user->uid;
|
||||
$config['where']['approval.action'] = 0;
|
||||
}
|
||||
if(isset($p['phase']) && $p['phase'] == 'approve'){//借用审批中
|
||||
$config['where']['status'] = ['in',[10,11,13]];
|
||||
}elseif(isset($p['phase']) && $p['phase'] == 'repaid'){//已全部归还
|
||||
$config['where']['status'] = 17;
|
||||
}elseif(isset($p['phase']) && $p['phase'] == 'unreturned'){ //未归还
|
||||
$config['where']['status'] = ['in',[12,14,15,16,18]];
|
||||
}
|
||||
$config['order']['id'] = 'desc';
|
||||
break;
|
||||
case 'show':
|
||||
$config['where']['id'] = $p['id'];
|
||||
//$config['where']['status'] = 1;
|
||||
break;
|
||||
case 'store':
|
||||
break;
|
||||
case 'update':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
case 'destroy':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批
|
||||
* @param Request $request
|
||||
* @return mixed|string[]|null
|
||||
*/
|
||||
public function serviceApprove(Request $request)
|
||||
{
|
||||
$param = $request->all();
|
||||
$attachment = $param['attachment']??[];
|
||||
//$appro = new \app\common\utils\approval\service\ApprovalFlowService();
|
||||
$appro = new \app\common\service\InvRequisitionApproveService();
|
||||
//requisition_id:申请id
|
||||
//approve_id:审核步骤id
|
||||
//action:审批动作:1通过 2驳回
|
||||
//comment:审批意见
|
||||
return $appro->approve($param['requisition_id'], $param['approve_id'], $request->user->uid, $param['action'], $param['comment'], $attachment);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员代领
|
||||
* 调用 common 服务层方法
|
||||
*/
|
||||
public function proxyClaim(Request $request, array $data): array
|
||||
{
|
||||
return (new \app\common\service\InvRequisitionService())->proxyClaim($request, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码/代领记录查询
|
||||
* 调用 common 服务层方法
|
||||
*/
|
||||
public function qrClaimsList(Request $request): array
|
||||
{
|
||||
return (new \app\common\service\InvRequisitionService())->qrClaimsList($request);
|
||||
}
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\model\CrmCustomer;
|
||||
use app\common\utils\InquiryFairAssigner;
|
||||
use app\common\utils\email\InquiryDigestMail;
|
||||
use app\common\utils\email\MailSend;
|
||||
use app\crm\model\Country;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 合塑谷歌AI客服询盘导入
|
||||
*
|
||||
* AI客服询盘 Excel(库外数据)-> 建 crm_customer 档案 -> 轮询分配 -> 汇总邮件。
|
||||
* 固定口径:corp_id=1(合塑)、channel_id=4(合塑谷歌)、type=2(导入)、magnitude=1(银牌);
|
||||
* contacts=第一个非空联系方式(WhatsApp->手机->邮箱);去重:手机/WhatsApp/邮箱任一重复即跳过。
|
||||
* 未对应库字段的内容分块拼入 desc_collect(询盘信息/意向车型/其他备注/AI评估/完整对话)。
|
||||
*/
|
||||
class HesuGoogleAiInquiryImportCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'inquiry:import-hesu-google-ai';
|
||||
protected static $defaultDescription = '合塑谷歌AI客服询盘导入:新询盘建档+轮询分配+汇总邮件';
|
||||
|
||||
private const CORP_ID = 1;
|
||||
private const CHANNEL_ID = 4; // 合塑谷歌
|
||||
private const CHANNEL_NAME = '合塑谷歌(AI客服)';
|
||||
private const TYPE = 2; // 导入数据
|
||||
private const GRADE = 0;
|
||||
private const MAGNITUDE = 1; // 银牌商机
|
||||
|
||||
/** @var array 国家名(小写)->country_id 缓存 */
|
||||
private $countryCache = [];
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Excel 文件路径', 'docs/AI客服询盘_2026-08-20.xlsx')
|
||||
->addOption('users', null, InputOption::VALUE_REQUIRED, '目标 user_id 列表,逗号分隔(必填)', '')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, '只解析+去重预检+分配预演,不写库不发邮件')
|
||||
->addOption('no-notify', null, InputOption::VALUE_NONE, '只建档分配,不发邮件')
|
||||
->addOption('notify-only', null, InputOption::VALUE_NONE, '跳过写库,按 manifest 补发未成功的邮件')
|
||||
->addOption('only-user', null, InputOption::VALUE_REQUIRED, '仅给指定 user_id 发邮件(试发)', '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Excel(按表头名定位列,顺序无关)
|
||||
*/
|
||||
private function parseExcel(string $filePath): array
|
||||
{
|
||||
$sheet = IOFactory::load($filePath)->getActiveSheet();
|
||||
$highestRow = $sheet->getHighestRow();
|
||||
|
||||
$colMap = [];
|
||||
foreach (range('A', 'Z') as $col) {
|
||||
$v = trim((string)($sheet->getCell($col . '1')->getValue() ?? ''));
|
||||
if ($v !== '') {
|
||||
$colMap[$v] = $col;
|
||||
}
|
||||
}
|
||||
foreach (['手机', 'WhatsApp', '邮箱'] as $need) {
|
||||
if (!isset($colMap[$need])) {
|
||||
throw new \RuntimeException('Excel 缺少必需列头: ' . $need . ';实际列头: ' . implode(',', array_keys($colMap)));
|
||||
}
|
||||
}
|
||||
|
||||
$get = function (string $header, int $r) use ($sheet, $colMap): string {
|
||||
if (!isset($colMap[$header])) {
|
||||
return '';
|
||||
}
|
||||
return trim((string)($sheet->getCell($colMap[$header] . $r)->getValue() ?? ''));
|
||||
};
|
||||
|
||||
$rows = [];
|
||||
for ($r = 2; $r <= $highestRow; $r++) {
|
||||
$row = [
|
||||
'excel_row' => $r,
|
||||
'phone' => $this->normalizeNumber($get('手机', $r)),
|
||||
'whatsapp' => $this->normalizeNumber($get('WhatsApp', $r)),
|
||||
'email' => $get('邮箱', $r),
|
||||
'wechat' => $get('微信', $r),
|
||||
'intent' => $get('意向', $r),
|
||||
'country' => $get('目标国家', $r),
|
||||
'budget' => $get('预算', $r),
|
||||
'brand' => $get('意向品牌', $r),
|
||||
'model' => $get('意向车型', $r),
|
||||
'qty' => $get('采购数量', $r),
|
||||
'usage' => $get('采购用途', $r),
|
||||
'note' => $get('其他备注', $r),
|
||||
'assess' => $get('评估理由', $r),
|
||||
'dialog' => $get('完整对话记录', $r),
|
||||
];
|
||||
if ($row['phone'] === '' && $row['whatsapp'] === '' && $row['email'] === ''
|
||||
&& $row['wechat'] === '' && $row['dialog'] === '') {
|
||||
continue; // 整行为空
|
||||
}
|
||||
// contacts:第一个非空联系方式
|
||||
$row['contacts'] = $row['whatsapp'] !== '' ? $row['whatsapp']
|
||||
: ($row['phone'] !== '' ? $row['phone'] : $row['email']);
|
||||
$row['intended'] = trim($row['brand'] . ' ' . $row['model']);
|
||||
$rows[] = $row;
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 号码归一化:仅保留数字与开头 +(去空格/横线等)
|
||||
*/
|
||||
private function normalizeNumber(string $raw): string
|
||||
{
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
$n = preg_replace('/[^0-9+]/', '', trim($raw));
|
||||
$n = preg_replace('/\++/', '+', $n);
|
||||
if ($n !== '' && $n[0] !== '+') {
|
||||
$n = '+' . $n;
|
||||
}
|
||||
return $n !== '+' ? $n : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 国家名(英文/中文,忽略大小写)匹配 country_id;匹配不上返回 0
|
||||
*/
|
||||
private function matchCountryId(string $name): int
|
||||
{
|
||||
if ($name === '') {
|
||||
return 0;
|
||||
}
|
||||
$key = strtolower($name);
|
||||
if (!array_key_exists($key, $this->countryCache)) {
|
||||
$lower = strtolower($name);
|
||||
$id = Country::where(function ($q) use ($lower) {
|
||||
$q->whereRaw('LOWER(en_addr) = ?', [$lower])
|
||||
->orWhereRaw('LOWER(cn_addr) = ?', [$lower]);
|
||||
})->value('id');
|
||||
$this->countryCache[$key] = (int)($id ?? 0);
|
||||
}
|
||||
return $this->countryCache[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* 未对应库字段的内容 -> 分块 HTML(desc_collect 富文本)
|
||||
*/
|
||||
private function buildDescCollect(array $row): string
|
||||
{
|
||||
$e = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES);
|
||||
$blocks = [];
|
||||
|
||||
$info = [];
|
||||
if ($row['intent'] !== '') {
|
||||
$info[] = '意向:' . $e($row['intent']);
|
||||
}
|
||||
if ($row['budget'] !== '') {
|
||||
$info[] = '预算:' . $e($row['budget']);
|
||||
}
|
||||
if ($row['qty'] !== '') {
|
||||
$info[] = '采购数量:' . $e($row['qty']);
|
||||
}
|
||||
if ($row['usage'] !== '') {
|
||||
$info[] = '采购用途:' . $e($row['usage']);
|
||||
}
|
||||
if ($row['country'] !== '') {
|
||||
$info[] = '目标国家:' . $e($row['country']);
|
||||
}
|
||||
if ($row['wechat'] !== '') {
|
||||
$info[] = '微信:' . $e($row['wechat']);
|
||||
}
|
||||
if ($info) {
|
||||
$blocks[] = $this->block('询盘信息', implode('<br>', $info));
|
||||
}
|
||||
|
||||
if ($row['brand'] !== '' || $row['model'] !== '') {
|
||||
$blocks[] = $this->block('意向车型',
|
||||
($row['brand'] !== '' ? '品牌:' . $e($row['brand']) : '')
|
||||
. ($row['brand'] !== '' && $row['model'] !== '' ? '<br>' : '')
|
||||
. ($row['model'] !== '' ? '车型:' . $e($row['model']) : ''));
|
||||
}
|
||||
|
||||
if ($row['note'] !== '') {
|
||||
$blocks[] = $this->block('其他备注', $e($row['note']));
|
||||
}
|
||||
|
||||
$ai = [];
|
||||
if ($row['intent'] !== '') {
|
||||
$ai[] = '意向等级:' . $e($row['intent']);
|
||||
}
|
||||
if ($row['assess'] !== '') {
|
||||
$ai[] = '评估理由:' . $e($row['assess']);
|
||||
}
|
||||
if ($ai) {
|
||||
$blocks[] = $this->block('AI评估', implode('<br>', $ai));
|
||||
}
|
||||
|
||||
if ($row['dialog'] !== '') {
|
||||
$blocks[] = $this->block('完整对话',
|
||||
'<div style="white-space:pre-wrap;background:#f7f8fa;border:1px solid #eee;'
|
||||
. 'border-radius:4px;padding:8px;font-size:12px;line-height:1.6;">' . $e($row['dialog']) . '</div>');
|
||||
}
|
||||
|
||||
return implode('', $blocks);
|
||||
}
|
||||
|
||||
private function block(string $title, string $bodyHtml): string
|
||||
{
|
||||
return '<div style="margin:0 0 10px;">'
|
||||
. '<div style="font-weight:700;color:#BB0010;border-left:3px solid #BB0010;'
|
||||
. 'padding-left:6px;margin-bottom:4px;">【' . htmlspecialchars($title) . '】</div>'
|
||||
. '<div style="color:#333;">' . $bodyHtml . '</div></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* 去重:手机/WhatsApp/邮箱任一与库内对应列重复(含文件内)即跳过
|
||||
*/
|
||||
private function dedup(array $rows, OutputInterface $output): array
|
||||
{
|
||||
$dbSets = [];
|
||||
foreach (['phone' => 'phone', 'whatsapp' => 'WhatsApp', 'email' => 'email'] as $field => $col) {
|
||||
$vals = array_values(array_unique(array_filter(array_column($rows, $field))));
|
||||
$set = [];
|
||||
foreach (array_chunk($vals, 500) as $chunk) {
|
||||
foreach (Db::table('crm_customer')->whereNull('deleted_at')->whereIn($col, $chunk)->pluck($col) as $v) {
|
||||
$v = trim((string)$v);
|
||||
if ($v !== '') {
|
||||
if ($col === 'email') {
|
||||
$set[strtolower($v)] = 1;
|
||||
} else {
|
||||
$set[$this->normalizeNumber($v)] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$dbSets[$field] = $set;
|
||||
}
|
||||
|
||||
$keep = []; $skipped = []; $noContact = [];
|
||||
$seen = ['phone' => [], 'whatsapp' => [], 'email' => []];
|
||||
foreach ($rows as $row) {
|
||||
if ($row['phone'] === '' && $row['whatsapp'] === '' && $row['email'] === '') {
|
||||
$noContact[] = $row;
|
||||
continue;
|
||||
}
|
||||
$dupReason = '';
|
||||
foreach (['phone', 'whatsapp', 'email'] as $field) {
|
||||
if ($row[$field] === '') {
|
||||
continue;
|
||||
}
|
||||
$key = $field === 'email' ? strtolower($row[$field]) : $row[$field];
|
||||
if (isset($dbSets[$field][$key]) || isset($seen[$field][$key])) {
|
||||
$dupReason = $field . '重复: ' . $row[$field];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($dupReason !== '') {
|
||||
$skipped[] = [$row['excel_row'], $dupReason];
|
||||
continue;
|
||||
}
|
||||
foreach (['phone', 'whatsapp', 'email'] as $field) {
|
||||
if ($row[$field] !== '') {
|
||||
$key = $field === 'email' ? strtolower($row[$field]) : $row[$field];
|
||||
$seen[$field][$key] = 1;
|
||||
}
|
||||
}
|
||||
$keep[] = $row;
|
||||
}
|
||||
return [$keep, $skipped, $noContact];
|
||||
}
|
||||
|
||||
private function manifestPath(string $date): string
|
||||
{
|
||||
return runtime_path() . '/inquiry_hesu_google_ai_' . $date . '.json';
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$file = $input->getOption('file');
|
||||
$targetUserIds = array_values(array_filter(array_map('intval', explode(',', (string)$input->getOption('users')))));
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$notifyOnly = (bool)$input->getOption('notify-only');
|
||||
$noNotify = (bool)$input->getOption('no-notify');
|
||||
$onlyUser = (int)$input->getOption('only-user');
|
||||
$date = date('Y-m-d');
|
||||
|
||||
if (!$targetUserIds) {
|
||||
$output->writeln('<error>--users 不能为空(目标业务员 user_id 列表)</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (!is_file($file)) {
|
||||
$output->writeln('<error>文件不存在: ' . $file . '</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($notifyOnly && $noNotify) {
|
||||
$output->writeln('<error>--notify-only 与 --no-notify 不能同时使用</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln('<info>解析 Excel: ' . $file . '</info>');
|
||||
$rows = $this->parseExcel($file);
|
||||
$output->writeln('<info>数据行: ' . count($rows) . '</info>');
|
||||
|
||||
[$keep, $skipped, $noContact] = $this->dedup($rows, $output);
|
||||
$output->writeln('<info>去重后待建档: ' . count($keep) . ',重复跳过: ' . count($skipped) . ',无联系方式跳过: ' . count($noContact) . '</info>');
|
||||
foreach (array_slice($skipped, 0, 10) as $s) {
|
||||
$output->writeln('<comment> 跳过 行' . $s[0] . ' ' . $s[1] . '</comment>');
|
||||
}
|
||||
if (count($skipped) > 10) {
|
||||
$output->writeln('<comment> ...另 ' . (count($skipped) - 10) . ' 条</comment>');
|
||||
}
|
||||
foreach (array_slice($noContact, 0, 5) as $row) {
|
||||
$output->writeln('<comment> 无联系方式 行' . $row['excel_row'] . ' 备注:' . mb_substr($row['note'], 0, 30) . '</comment>');
|
||||
}
|
||||
|
||||
// 国家匹配预览
|
||||
$countryMiss = [];
|
||||
foreach ($keep as $row) {
|
||||
if ($row['country'] !== '' && $this->matchCountryId($row['country']) === 0) {
|
||||
$countryMiss[$row['country']] = 1;
|
||||
}
|
||||
}
|
||||
if ($countryMiss) {
|
||||
$output->writeln('<comment>未匹配到国家(将留空): ' . implode('、', array_keys($countryMiss)) . '</comment>');
|
||||
}
|
||||
|
||||
// 公平分配:按近10天窗口内实际分到数升序(少者优先、最久未分到优先),再按序轮询
|
||||
$fair = InquiryFairAssigner::fairOrder($targetUserIds);
|
||||
$orderedUsers = $fair['ordered'];
|
||||
$output->writeln('<info>分配顺序(近' . $fair['window_days'] . '天已有条数):'
|
||||
. implode('、', array_map(fn($u) => $u . '(' . ($fair['counts'][$u] ?? 0) . ')', $orderedUsers)) . '</info>');
|
||||
$n = count($orderedUsers);
|
||||
foreach ($keep as $i => &$row) {
|
||||
$row['new_user_id'] = $orderedUsers[$i % $n];
|
||||
}
|
||||
unset($row);
|
||||
$groups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$groups[$uid] = [];
|
||||
}
|
||||
foreach ($keep as $row) {
|
||||
$groups[$row['new_user_id']][] = $row;
|
||||
}
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$output->writeln('<info>user ' . $uid . ' 分配: ' . count($groups[$uid]) . ' 条</info>');
|
||||
}
|
||||
foreach (array_slice($keep, 0, 3) as $row) {
|
||||
$output->writeln('<comment>样例[行' . $row['excel_row'] . '] ' . $row['contacts'] . ' '
|
||||
. mb_substr($row['intended'], 0, 24) . ' -> user ' . $row['new_user_id'] . '</comment>');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('<info>[dry-run] 不写库、不发邮件。确认后去掉 --dry-run 执行。</info>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$manifestPath = $this->manifestPath($date);
|
||||
|
||||
if ($notifyOnly) {
|
||||
// 建档后联系方式已入库,重新解析会被去重清空——补发必须用 manifest 保存的分配结果
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)@file_get_contents($manifestPath), true) ?: []) : [];
|
||||
if (empty($manifest['groups'])) {
|
||||
$output->writeln('<error>manifest 不存在或无分配数据: ' . $manifestPath . ';请先执行 --no-notify 或完整命令生成</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$digestGroups = $manifest['groups'];
|
||||
$output->writeln('<comment>[notify-only] 跳过写库,按 manifest 分配结果补发。</comment>');
|
||||
} else {
|
||||
$created = [];
|
||||
Db::connection()->transaction(function () use ($keep, $output, &$created) {
|
||||
foreach ($keep as $row) {
|
||||
$c = new CrmCustomer();
|
||||
$c->corp_id = self::CORP_ID;
|
||||
$c->user_id = $row['new_user_id'];
|
||||
$c->type = self::TYPE;
|
||||
$c->channel_id = self::CHANNEL_ID;
|
||||
$c->grade = self::GRADE;
|
||||
$c->magnitude = self::MAGNITUDE;
|
||||
$c->email = $row['email'];
|
||||
$c->phone = $row['phone'];
|
||||
$c->WhatsApp = $row['whatsapp'];
|
||||
$c->contacts = $row['contacts'];
|
||||
$c->country_id = $this->matchCountryId($row['country']) ?: null;
|
||||
$c->intended_vehicles = $row['intended'] !== '' ? $row['intended'] : null;
|
||||
$c->desc_collect = $this->buildDescCollect($row);
|
||||
$c->obtain = time();
|
||||
if (!$c->save()) {
|
||||
throw new \RuntimeException('行' . $row['excel_row'] . ' 建档失败');
|
||||
}
|
||||
$row['code'] = $c->code;
|
||||
$created[] = $row;
|
||||
}
|
||||
$output->writeln('<info>已建档并分配: ' . count($created) . ' 条(code 自动生成)</info>');
|
||||
});
|
||||
|
||||
if ($created) {
|
||||
$digestGroups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$digestGroups[(string)$uid] = [];
|
||||
}
|
||||
foreach ($created as $row) {
|
||||
$digestGroups[(string)$row['new_user_id']][] = $this->digestRow($row);
|
||||
}
|
||||
$this->saveManifest($manifestPath, $targetUserIds, $digestGroups);
|
||||
} elseif (is_file($manifestPath)) {
|
||||
$manifest = json_decode((string)file_get_contents($manifestPath), true) ?: [];
|
||||
if (!empty($manifest['groups'])) {
|
||||
$digestGroups = $manifest['groups'];
|
||||
$output->writeln('<comment>本次无新建档(可能已导入过),沿用已有 manifest 的分配结果。</comment>');
|
||||
} else {
|
||||
$output->writeln('<comment>没有可建档数据,结束。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
} else {
|
||||
$output->writeln('<comment>没有可建档数据,结束。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($noNotify) {
|
||||
$output->writeln('<comment>[no-notify] 已建档,跳过发邮件。核对库后用 --notify-only 补发。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendDigests($digestGroups, $targetUserIds, $manifestPath, $onlyUser, $output);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function saveManifest(string $path, array $targetUserIds, array $digestGroups): void
|
||||
{
|
||||
$perUser = []; $emailed = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$perUser[(string)$uid] = count($digestGroups[(string)$uid] ?? []);
|
||||
$emailed[(string)$uid] = false;
|
||||
}
|
||||
$data = [
|
||||
'date' => date('Y-m-d'),
|
||||
'imported_at' => time(),
|
||||
'target_users' => $targetUserIds,
|
||||
'per_user_counts' => $perUser,
|
||||
'groups' => $digestGroups,
|
||||
'emailed' => $emailed,
|
||||
];
|
||||
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* 建档行 -> 邮件汇总行
|
||||
*/
|
||||
private function digestRow(array $row): array
|
||||
{
|
||||
$remark = trim($row['intended'] . ' ' . $row['budget']);
|
||||
return [
|
||||
'code' => $row['code'] ?? '',
|
||||
'contact' => $row['contacts'],
|
||||
'email' => $row['email'],
|
||||
'phone' => $row['whatsapp'] !== '' ? $row['whatsapp'] : $row['phone'],
|
||||
'country_cn' => $row['country'],
|
||||
'channel' => self::CHANNEL_NAME,
|
||||
'remark' => mb_substr($remark, 0, 100),
|
||||
];
|
||||
}
|
||||
|
||||
private function sendDigests(array $digestGroups, array $targetUserIds, string $manifestPath, int $onlyUser, OutputInterface $output): void
|
||||
{
|
||||
$users = Db::table('user')->whereIn('id', $targetUserIds)->get(['id', 'name', 'email']);
|
||||
$userMap = [];
|
||||
foreach ($users as $u) {
|
||||
$userMap[(int)$u->id] = $u;
|
||||
}
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)file_get_contents($manifestPath), true) ?: ['emailed' => []]) : ['emailed' => []];
|
||||
$date = date('Y-m-d');
|
||||
|
||||
foreach ($targetUserIds as $uid) {
|
||||
if (count($digestGroups[(string)$uid] ?? []) === 0) {
|
||||
$output->writeln('<comment>user ' . $uid . ' 未分到询盘,不发送</comment>');
|
||||
continue;
|
||||
}
|
||||
if ($onlyUser > 0 && $uid !== $onlyUser) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($manifest['emailed'][(string)$uid])) {
|
||||
$output->writeln('<comment>跳过已发送: user ' . $uid . '</comment>');
|
||||
continue;
|
||||
}
|
||||
$u = $userMap[$uid] ?? null;
|
||||
if (!$u || empty($u->email)) {
|
||||
$output->writeln('<error>user ' . $uid . ' 无邮箱,跳过</error>');
|
||||
continue;
|
||||
}
|
||||
$inquiries = array_values($digestGroups[(string)$uid] ?? []);
|
||||
$html = InquiryDigestMail::build((string)$u->name, $inquiries, $date);
|
||||
$subject = 'AI客服新询盘分配汇总(共 ' . count($inquiries) . ' 条)- ' . $u->name . ' - ' . $date;
|
||||
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
if (!$ok) {
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
}
|
||||
$manifest['emailed'][(string)$uid] = (bool)$ok;
|
||||
file_put_contents($manifestPath, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
|
||||
$tag = $ok ? 'info' : 'error';
|
||||
$output->writeln('<' . $tag . '>user ' . $uid . ' (' . $u->email . '): '
|
||||
. ($ok ? '发送成功' : '发送失败') . ',共 ' . count($inquiries) . ' 条</' . $tag . '>');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\model\CrmCustomer;
|
||||
use app\common\utils\InquiryFairAssigner;
|
||||
use app\common\utils\email\InquiryDigestMail;
|
||||
use app\common\utils\email\MailSend;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 合塑谷歌网站询盘导入
|
||||
*
|
||||
* Excel 新询盘(库外数据)-> 建 crm_customer 档案 -> 按行序轮询分配业务员 -> 每人一封汇总邮件。
|
||||
* 固定口径:corp_id=1(合塑)、channel_id=4(合塑谷歌)、type=2(导入数据);
|
||||
* 去重:电话或邮箱与现有未删除客户重复(含本文件内重复)则跳过。
|
||||
*/
|
||||
class HesuGoogleInquiryImportCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'inquiry:import-hesu-google';
|
||||
protected static $defaultDescription = '合塑谷歌网站询盘导入:新询盘建档+轮询分配+汇总邮件';
|
||||
|
||||
private const CORP_ID = 1;
|
||||
private const CHANNEL_ID = 4; // 合塑谷歌
|
||||
private const CHANNEL_NAME = '合塑谷歌';
|
||||
private const TYPE = 2; // 导入数据
|
||||
private const GRADE = 0;
|
||||
private const MAGNITUDE = 2;
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Excel 文件路径', 'docs/询盘明细_2026年6-8月_已清洗.xlsx')
|
||||
->addOption('users', null, InputOption::VALUE_REQUIRED, '目标 user_id 列表,逗号分隔(必填)', '')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, '只解析+去重预检+分配预演,不写库不发邮件')
|
||||
->addOption('no-notify', null, InputOption::VALUE_NONE, '只建档分配,不发邮件(先核对库再 --notify-only 补发)')
|
||||
->addOption('notify-only', null, InputOption::VALUE_NONE, '跳过写库,按 manifest 补发未成功的邮件')
|
||||
->addOption('only-user', null, InputOption::VALUE_REQUIRED, '仅给指定 user_id 发邮件(试发)', '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Excel(按表头名定位列,顺序无关)。返回行数组。
|
||||
*/
|
||||
private function parseExcel(string $filePath): array
|
||||
{
|
||||
$sheet = IOFactory::load($filePath)->getActiveSheet();
|
||||
$highestRow = $sheet->getHighestRow();
|
||||
|
||||
$colMap = [];
|
||||
foreach (range('A', 'Z') as $col) {
|
||||
$v = trim((string)($sheet->getCell($col . '1')->getValue() ?? ''));
|
||||
if ($v !== '') {
|
||||
$colMap[$v] = $col;
|
||||
}
|
||||
}
|
||||
$required = ['客户姓名', '电话/WhatsApp', '邮箱', '留言内容', '产品页链接'];
|
||||
$missing = array_diff($required, array_keys($colMap));
|
||||
if ($missing) {
|
||||
throw new \RuntimeException('Excel 缺少必需列头: ' . implode(', ', $missing) . ';实际列头: ' . implode(',', array_keys($colMap)));
|
||||
}
|
||||
|
||||
$get = function (string $header, int $r) use ($sheet, $colMap): string {
|
||||
return trim((string)($sheet->getCell($colMap[$header] . $r)->getValue() ?? ''));
|
||||
};
|
||||
|
||||
$rows = [];
|
||||
for ($r = 2; $r <= $highestRow; $r++) {
|
||||
$row = [
|
||||
'contacts' => $get('客户姓名', $r),
|
||||
'phone' => $get('电话/WhatsApp', $r),
|
||||
'email' => $get('邮箱', $r),
|
||||
'message' => $get('留言内容', $r),
|
||||
'url' => $get('产品页链接', $r),
|
||||
'excel_row' => $r,
|
||||
];
|
||||
if ($row['contacts'] === '' && $row['phone'] === '' && $row['email'] === '') {
|
||||
continue; // 整行为空
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从产品页链接提取车型 slug,如 /product/rox-01/ -> rox-01
|
||||
*/
|
||||
private function productSlug(string $url): string
|
||||
{
|
||||
$path = parse_url($url, PHP_URL_PATH) ?: '';
|
||||
$segs = array_values(array_filter(explode('/', $path), fn($s) => $s !== ''));
|
||||
if (empty($segs)) {
|
||||
return '';
|
||||
}
|
||||
$i = array_search('product', $segs, true);
|
||||
if ($i !== false && isset($segs[$i + 1])) {
|
||||
return $segs[$i + 1];
|
||||
}
|
||||
return (string)end($segs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 库内 + 文件内去重。返回 [待建档行, 跳过信息列表]
|
||||
*/
|
||||
private function dedup(array $rows, OutputInterface $output): array
|
||||
{
|
||||
$dbPhones = [];
|
||||
foreach (array_chunk(array_filter(array_column($rows, 'phone')), 500) as $chunk) {
|
||||
$dbPhones = array_merge($dbPhones, Db::table('crm_customer')
|
||||
->whereNull('deleted_at')->whereIn('phone', $chunk)->pluck('phone')->map(fn($v) => trim((string)$v))->all());
|
||||
}
|
||||
$dbEmails = [];
|
||||
foreach (array_chunk(array_filter(array_column($rows, 'email')), 500) as $chunk) {
|
||||
$dbEmails = array_merge($dbEmails, Db::table('crm_customer')
|
||||
->whereNull('deleted_at')->whereIn('email', $chunk)->pluck('email')->map(fn($v) => trim((string)$v))->all());
|
||||
}
|
||||
$dbPhones = array_flip($dbPhones);
|
||||
$dbEmails = array_flip($dbEmails);
|
||||
|
||||
$keep = []; $skipped = []; $noContact = [];
|
||||
$seenPhone = []; $seenEmail = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row['phone'] === '' && $row['email'] === '') {
|
||||
$noContact[] = $row;
|
||||
continue;
|
||||
}
|
||||
if ($row['phone'] !== '' && (isset($dbPhones[$row['phone']]) || isset($seenPhone[$row['phone']]))) {
|
||||
$skipped[] = [$row['excel_row'], '电话重复: ' . $row['phone']];
|
||||
$seenPhone[$row['phone']] = 1;
|
||||
continue;
|
||||
}
|
||||
if ($row['email'] !== '' && (isset($dbEmails[$row['email']]) || isset($seenEmail[$row['email']]))) {
|
||||
$skipped[] = [$row['excel_row'], '邮箱重复: ' . $row['email']];
|
||||
$seenEmail[$row['email']] = 1;
|
||||
continue;
|
||||
}
|
||||
if ($row['phone'] !== '') {
|
||||
$seenPhone[$row['phone']] = 1;
|
||||
}
|
||||
if ($row['email'] !== '') {
|
||||
$seenEmail[$row['email']] = 1;
|
||||
}
|
||||
$keep[] = $row;
|
||||
}
|
||||
return [$keep, $skipped, $noContact];
|
||||
}
|
||||
|
||||
private function manifestPath(string $date): string
|
||||
{
|
||||
return runtime_path() . '/inquiry_hesu_google_import_' . $date . '.json';
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$file = $input->getOption('file');
|
||||
$targetUserIds = array_values(array_filter(array_map('intval', explode(',', (string)$input->getOption('users')))));
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$notifyOnly = (bool)$input->getOption('notify-only');
|
||||
$noNotify = (bool)$input->getOption('no-notify');
|
||||
$onlyUser = (int)$input->getOption('only-user');
|
||||
$date = date('Y-m-d');
|
||||
|
||||
if (!$targetUserIds) {
|
||||
$output->writeln('<error>--users 不能为空(目标业务员 user_id 列表)</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (!is_file($file)) {
|
||||
$output->writeln('<error>文件不存在: ' . $file . '</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($notifyOnly && $noNotify) {
|
||||
$output->writeln('<error>--notify-only 与 --no-notify 不能同时使用</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln('<info>解析 Excel: ' . $file . '</info>');
|
||||
$rows = $this->parseExcel($file);
|
||||
$output->writeln('<info>数据行: ' . count($rows) . '</info>');
|
||||
|
||||
[$keep, $skipped, $noContact] = $this->dedup($rows, $output);
|
||||
$output->writeln('<info>去重后待建档: ' . count($keep) . ',重复跳过: ' . count($skipped) . ',无联系方式跳过: ' . count($noContact) . '</info>');
|
||||
foreach (array_slice($skipped, 0, 10) as $s) {
|
||||
$output->writeln('<comment> 跳过 行' . $s[0] . ' ' . $s[1] . '</comment>');
|
||||
}
|
||||
if (count($skipped) > 10) {
|
||||
$output->writeln('<comment> ...另 ' . (count($skipped) - 10) . ' 条</comment>');
|
||||
}
|
||||
foreach ($noContact as $row) {
|
||||
$output->writeln('<comment> 无联系方式 行' . $row['excel_row'] . ' ' . $row['contacts'] . '</comment>');
|
||||
}
|
||||
|
||||
// 公平分配:按近10天窗口内实际分到数升序(少者优先、最久未分到优先),再按序轮询
|
||||
$fair = InquiryFairAssigner::fairOrder($targetUserIds);
|
||||
$orderedUsers = $fair['ordered'];
|
||||
$output->writeln('<info>分配顺序(近' . $fair['window_days'] . '天已有条数):'
|
||||
. implode('、', array_map(fn($u) => $u . '(' . ($fair['counts'][$u] ?? 0) . ')', $orderedUsers)) . '</info>');
|
||||
$n = count($orderedUsers);
|
||||
foreach ($keep as $i => &$row) {
|
||||
$row['new_user_id'] = $orderedUsers[$i % $n];
|
||||
}
|
||||
unset($row);
|
||||
$groups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$groups[$uid] = [];
|
||||
}
|
||||
foreach ($keep as $row) {
|
||||
$groups[$row['new_user_id']][] = $row;
|
||||
}
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$output->writeln('<info>user ' . $uid . ' 分配: ' . count($groups[$uid]) . ' 条</info>');
|
||||
}
|
||||
foreach ($keep as $i => $row) {
|
||||
if ($i < 3) {
|
||||
$output->writeln('<comment>样例[行' . $row['excel_row'] . '] ' . $row['contacts'] . ' ' . $row['phone'] . ' '
|
||||
. $this->productSlug($row['url']) . ' -> user ' . $row['new_user_id'] . '</comment>');
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('<info>[dry-run] 不写库、不发邮件。确认后去掉 --dry-run 执行。</info>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$notifyOnly) {
|
||||
$created = [];
|
||||
Db::connection()->transaction(function () use ($keep, $targetUserIds, $output, &$created) {
|
||||
foreach ($keep as $row) {
|
||||
$desc = $row['message'];
|
||||
if ($row['url'] !== '') {
|
||||
$desc .= ($desc !== '' ? "\n" : '') . '产品页:' . $row['url'];
|
||||
}
|
||||
$c = new CrmCustomer();
|
||||
$c->corp_id = self::CORP_ID;
|
||||
$c->user_id = $row['new_user_id'];
|
||||
$c->type = self::TYPE;
|
||||
$c->channel_id = self::CHANNEL_ID;
|
||||
$c->grade = self::GRADE;
|
||||
$c->magnitude = self::MAGNITUDE;
|
||||
$c->email = $row['email'];
|
||||
$c->phone = $row['phone'];
|
||||
$c->WhatsApp = '';
|
||||
$c->contacts = $row['contacts'];
|
||||
$c->desc_collect = $desc;
|
||||
$slug = $this->productSlug($row['url']);
|
||||
$c->intended_vehicles = $slug !== '' ? $slug : null;
|
||||
$c->obtain = time();
|
||||
if (!$c->save()) {
|
||||
throw new \RuntimeException('行' . $row['excel_row'] . ' 建档失败');
|
||||
}
|
||||
$row['code'] = $c->code;
|
||||
$created[] = $row;
|
||||
}
|
||||
$output->writeln('<info>已建档并分配: ' . count($created) . ' 条(code 由模型自动生成)</info>');
|
||||
});
|
||||
$keep = $created;
|
||||
$groups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$groups[$uid] = [];
|
||||
}
|
||||
foreach ($keep as $row) {
|
||||
$groups[$row['new_user_id']][] = $row;
|
||||
}
|
||||
} else {
|
||||
$output->writeln('<comment>[notify-only] 跳过写库。</comment>');
|
||||
}
|
||||
|
||||
$manifestPath = $this->manifestPath($date);
|
||||
|
||||
if ($notifyOnly) {
|
||||
// 建档后号码/邮箱已入库,重新解析会被去重清空——补发必须用 manifest 保存的分配结果
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)@file_get_contents($manifestPath), true) ?: []) : [];
|
||||
if (empty($manifest['groups'])) {
|
||||
$output->writeln('<error>manifest 不存在或无分配数据: ' . $manifestPath . ';请先执行 --no-notify 或完整命令生成</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$digestGroups = $manifest['groups'];
|
||||
} else {
|
||||
$totalRows = array_sum(array_map('count', $groups));
|
||||
if ($totalRows > 0) {
|
||||
$digestGroups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$digestGroups[(string)$uid] = [];
|
||||
}
|
||||
foreach ($groups as $uid => $rows) {
|
||||
foreach ($rows as $row) {
|
||||
$digestGroups[(string)$uid][] = $this->digestRow($row);
|
||||
}
|
||||
}
|
||||
$this->saveManifest($manifestPath, $targetUserIds, $digestGroups);
|
||||
} else {
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)@file_get_contents($manifestPath), true) ?: []) : [];
|
||||
if (!empty($manifest['groups'])) {
|
||||
$digestGroups = $manifest['groups'];
|
||||
$output->writeln('<comment>本次无新建档(可能已导入过),沿用已有 manifest 的分配结果。</comment>');
|
||||
} else {
|
||||
$output->writeln('<comment>没有可建档数据,结束。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
if ($noNotify) {
|
||||
$output->writeln('<comment>[no-notify] 已建档,跳过发邮件。核对库后用 --notify-only 补发。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendDigests($digestGroups, $targetUserIds, $manifestPath, $onlyUser, $output);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function saveManifest(string $path, array $targetUserIds, array $digestGroups): void
|
||||
{
|
||||
$perUser = []; $emailed = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$perUser[(string)$uid] = count($digestGroups[(string)$uid] ?? []);
|
||||
$emailed[(string)$uid] = false;
|
||||
}
|
||||
$data = [
|
||||
'date' => date('Y-m-d'),
|
||||
'imported_at' => time(),
|
||||
'target_users' => $targetUserIds,
|
||||
'per_user_counts' => $perUser,
|
||||
'groups' => $digestGroups,
|
||||
'emailed' => $emailed,
|
||||
];
|
||||
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* 建档行 -> 邮件汇总行
|
||||
*/
|
||||
private function digestRow(array $row): array
|
||||
{
|
||||
return [
|
||||
'code' => $row['code'] ?? '',
|
||||
'contact' => $row['contacts'],
|
||||
'email' => $row['email'],
|
||||
'phone' => $row['phone'],
|
||||
'country_cn' => '',
|
||||
'channel' => self::CHANNEL_NAME,
|
||||
'remark' => $row['message'],
|
||||
];
|
||||
}
|
||||
|
||||
private function sendDigests(array $digestGroups, array $targetUserIds, string $manifestPath, int $onlyUser, OutputInterface $output): void
|
||||
{
|
||||
$users = Db::table('user')->whereIn('id', $targetUserIds)->get(['id', 'name', 'email']);
|
||||
$userMap = [];
|
||||
foreach ($users as $u) {
|
||||
$userMap[(int)$u->id] = $u;
|
||||
}
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)file_get_contents($manifestPath), true) ?: ['emailed' => []]) : ['emailed' => []];
|
||||
$date = date('Y-m-d');
|
||||
|
||||
foreach ($targetUserIds as $uid) {
|
||||
if (count($digestGroups[(string)$uid] ?? []) === 0) {
|
||||
$output->writeln('<comment>user ' . $uid . ' 未分到询盘,不发送</comment>');
|
||||
continue;
|
||||
}
|
||||
if ($onlyUser > 0 && $uid !== $onlyUser) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($manifest['emailed'][(string)$uid])) {
|
||||
$output->writeln('<comment>跳过已发送: user ' . $uid . '</comment>');
|
||||
continue;
|
||||
}
|
||||
$u = $userMap[$uid] ?? null;
|
||||
if (!$u || empty($u->email)) {
|
||||
$output->writeln('<error>user ' . $uid . ' 无邮箱,跳过</error>');
|
||||
continue;
|
||||
}
|
||||
$inquiries = array_values($digestGroups[(string)$uid] ?? []);
|
||||
$html = InquiryDigestMail::build((string)$u->name, $inquiries, $date);
|
||||
$subject = '新询盘分配汇总(共 ' . count($inquiries) . ' 条)- ' . $u->name . ' - ' . $date;
|
||||
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
if (!$ok) {
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
}
|
||||
$manifest['emailed'][(string)$uid] = (bool)$ok;
|
||||
file_put_contents($manifestPath, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
|
||||
$tag = $ok ? 'info' : 'error';
|
||||
$output->writeln('<' . $tag . '>user ' . $uid . ' (' . $u->email . '): '
|
||||
. ($ok ? '发送成功' : '发送失败') . ',共 ' . count($inquiries) . ' 条</' . $tag . '>');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\model\CrmCustomer;
|
||||
use app\common\service\OssService;
|
||||
use app\common\utils\InquiryFairAssigner;
|
||||
use app\common\utils\email\InquiryDigestMail;
|
||||
use app\common\utils\email\MailSend;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 合塑谷歌网站whatsapp询盘导入
|
||||
*
|
||||
* WhatsApp 询盘 Excel(库外数据)-> 截图传 OSS -> 建 crm_customer 档案 -> 轮询分配 -> 汇总邮件。
|
||||
* 固定口径:corp_id=1(合塑)、channel_id=4(合塑谷歌)、type=2(导入数据);
|
||||
* contacts=归一化号码、号码存 WhatsApp 字段;按号码去重(对比库内 WhatsApp/phone 两列 + 文件内)。
|
||||
*/
|
||||
class HesuGoogleWhatsAppImportCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'inquiry:import-hesu-google-wa';
|
||||
protected static $defaultDescription = '合塑谷歌网站whatsapp询盘导入:截图传OSS+建档+轮询分配+汇总邮件';
|
||||
|
||||
private const CORP_ID = 1;
|
||||
private const CHANNEL_ID = 4; // 合塑谷歌
|
||||
private const CHANNEL_NAME = '合塑谷歌(WhatsApp)';
|
||||
private const TYPE = 2; // 导入数据
|
||||
private const GRADE = 0;
|
||||
private const MAGNITUDE = 2;
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Excel 文件路径', 'docs/WhatsApp询盘_6-8月_明细.xlsx')
|
||||
->addOption('users', null, InputOption::VALUE_REQUIRED, '目标 user_id 列表,逗号分隔(必填)', '')
|
||||
->addOption('images-dir', null, InputOption::VALUE_REQUIRED, '截图所在目录', 'docs/whatsapp_screenshots')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, '只解析+去重预检+分配预演(不上传截图/不写库/不发邮件)')
|
||||
->addOption('no-notify', null, InputOption::VALUE_NONE, '只上传+建档分配,不发邮件')
|
||||
->addOption('notify-only', null, InputOption::VALUE_NONE, '跳过上传与写库,按 manifest 补发未成功的邮件')
|
||||
->addOption('only-user', null, InputOption::VALUE_REQUIRED, '仅给指定 user_id 发邮件(试发)', '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Excel(按表头名定位列)。返回行数组。
|
||||
*/
|
||||
private function parseExcel(string $filePath): array
|
||||
{
|
||||
$sheet = IOFactory::load($filePath)->getActiveSheet();
|
||||
$highestRow = $sheet->getHighestRow();
|
||||
|
||||
$colMap = [];
|
||||
foreach (range('A', 'Z') as $col) {
|
||||
$v = trim((string)($sheet->getCell($col . '1')->getValue() ?? ''));
|
||||
if ($v !== '') {
|
||||
$colMap[$v] = $col;
|
||||
}
|
||||
}
|
||||
if (!isset($colMap['WhatsApp号码'])) {
|
||||
throw new \RuntimeException('Excel 缺少必需列头: WhatsApp号码;实际列头: ' . implode(',', array_keys($colMap)));
|
||||
}
|
||||
|
||||
$get = function (string $header, int $r) use ($sheet, $colMap): string {
|
||||
if (!isset($colMap[$header])) {
|
||||
return '';
|
||||
}
|
||||
return trim((string)($sheet->getCell($colMap[$header] . $r)->getValue() ?? ''));
|
||||
};
|
||||
|
||||
$rows = [];
|
||||
for ($r = 2; $r <= $highestRow; $r++) {
|
||||
$number = $this->normalizeNumber($get('WhatsApp号码', $r));
|
||||
if ($number === '') {
|
||||
$blank = $get('最后留言', $r) === '' && $get('截图文件名', $r) === '';
|
||||
if ($blank) {
|
||||
continue; // 整行为空
|
||||
}
|
||||
$rows[] = ['excel_row' => $r, 'number' => '', 'message' => $get('最后留言', $r), 'shot' => $get('截图文件名', $r)];
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'excel_row' => $r,
|
||||
'number' => $number,
|
||||
'message' => $get('最后留言', $r),
|
||||
'shot' => $get('截图文件名', $r),
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 号码归一化:仅保留数字与开头 +(去横线/空格等)
|
||||
*/
|
||||
private function normalizeNumber(string $raw): string
|
||||
{
|
||||
$n = preg_replace('/[^0-9+]/', '', trim($raw));
|
||||
$n = preg_replace('/\++/', '+', $n);
|
||||
if ($n !== '' && $n[0] !== '+') {
|
||||
$n = '+' . $n;
|
||||
}
|
||||
return $n !== '+' ? $n : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 按号码去重:库内(WhatsApp 与 phone 两列)+ 文件内。返回 [待建档行, 跳过信息, 无号码行]
|
||||
*/
|
||||
private function dedup(array $rows, OutputInterface $output): array
|
||||
{
|
||||
$numbers = array_values(array_unique(array_filter(array_column($rows, 'number'))));
|
||||
$exists = [];
|
||||
foreach (array_chunk($numbers, 500) as $chunk) {
|
||||
foreach (['WhatsApp', 'phone'] as $col) {
|
||||
foreach (Db::table('crm_customer')->whereNull('deleted_at')->whereIn($col, $chunk)->pluck($col) as $v) {
|
||||
$n = $this->normalizeNumber((string)$v);
|
||||
if ($n !== '') {
|
||||
$exists[$n] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$keep = []; $skipped = []; $noNumber = []; $seen = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row['number'] === '') {
|
||||
$noNumber[] = $row;
|
||||
continue;
|
||||
}
|
||||
if (isset($exists[$row['number']]) || isset($seen[$row['number']])) {
|
||||
$skipped[] = [$row['excel_row'], '号码重复: ' . $row['number']];
|
||||
continue;
|
||||
}
|
||||
$seen[$row['number']] = 1;
|
||||
$keep[] = $row;
|
||||
}
|
||||
return [$keep, $skipped, $noNumber];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传截图到 OSS。返回 [对象键或null, 是否成功];文件缺失返回 [null, false]。
|
||||
*/
|
||||
private function uploadShot(string $imagesDir, string $filename, OutputInterface $output): ?string
|
||||
{
|
||||
if ($filename === '') {
|
||||
return null;
|
||||
}
|
||||
$path = rtrim($imagesDir, '/') . '/' . ltrim($filename, '/');
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
$mainDir = rtrim((string)(getenv('OSS_MAIN_DIR') ?: 'x_server'), '/');
|
||||
$key = $mainDir . '/crm_whatsapp/' . basename($filename);
|
||||
try {
|
||||
(new OssService())->uploadImage($path, $key); // 返回带签名的临时URL,这里只关心落 OSS 的对象键
|
||||
return $key;
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<comment> 截图上传失败 ' . $filename . ': ' . $e->getMessage() . '</comment>');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function manifestPath(string $date): string
|
||||
{
|
||||
return runtime_path() . '/inquiry_hesu_google_wa_' . $date . '.json';
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$file = $input->getOption('file');
|
||||
$imagesDir = (string)$input->getOption('images-dir');
|
||||
$targetUserIds = array_values(array_filter(array_map('intval', explode(',', (string)$input->getOption('users')))));
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$notifyOnly = (bool)$input->getOption('notify-only');
|
||||
$noNotify = (bool)$input->getOption('no-notify');
|
||||
$onlyUser = (int)$input->getOption('only-user');
|
||||
$date = date('Y-m-d');
|
||||
|
||||
if (!$targetUserIds) {
|
||||
$output->writeln('<error>--users 不能为空(目标业务员 user_id 列表)</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (!is_file($file)) {
|
||||
$output->writeln('<error>文件不存在: ' . $file . '</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($notifyOnly && $noNotify) {
|
||||
$output->writeln('<error>--notify-only 与 --no-notify 不能同时使用</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln('<info>解析 Excel: ' . $file . '</info>');
|
||||
$rows = $this->parseExcel($file);
|
||||
$output->writeln('<info>数据行: ' . count($rows) . '</info>');
|
||||
|
||||
[$keep, $skipped, $noNumber] = $this->dedup($rows, $output);
|
||||
$output->writeln('<info>去重后待建档: ' . count($keep) . ',号码重复跳过: ' . count($skipped) . ',无号码跳过: ' . count($noNumber) . '</info>');
|
||||
foreach (array_slice($skipped, 0, 10) as $s) {
|
||||
$output->writeln('<comment> 跳过 行' . $s[0] . ' ' . $s[1] . '</comment>');
|
||||
}
|
||||
if (count($skipped) > 10) {
|
||||
$output->writeln('<comment> ...另 ' . (count($skipped) - 10) . ' 条</comment>');
|
||||
}
|
||||
foreach (array_slice($noNumber, 0, 5) as $row) {
|
||||
$output->writeln('<comment> 无号码 行' . $row['excel_row'] . ' 留言:' . mb_substr($row['message'], 0, 30) . '</comment>');
|
||||
}
|
||||
|
||||
// 截图可用性
|
||||
$shotFound = 0; $shotMissing = 0;
|
||||
foreach ($keep as $row) {
|
||||
if ($row['shot'] === '') {
|
||||
continue;
|
||||
}
|
||||
if (is_file(rtrim($imagesDir, '/') . '/' . ltrim($row['shot'], '/'))) {
|
||||
$shotFound++;
|
||||
} else {
|
||||
$shotMissing++;
|
||||
}
|
||||
}
|
||||
$output->writeln('<info>截图: 找到 ' . $shotFound . ' / 缺失 ' . $shotMissing . '(目录: ' . $imagesDir . ')</info>');
|
||||
|
||||
// 公平分配:按近10天窗口内实际分到数升序(少者优先、最久未分到优先),再按序轮询
|
||||
$fair = InquiryFairAssigner::fairOrder($targetUserIds);
|
||||
$orderedUsers = $fair['ordered'];
|
||||
$output->writeln('<info>分配顺序(近' . $fair['window_days'] . '天已有条数):'
|
||||
. implode('、', array_map(fn($u) => $u . '(' . ($fair['counts'][$u] ?? 0) . ')', $orderedUsers)) . '</info>');
|
||||
$n = count($orderedUsers);
|
||||
foreach ($keep as $i => &$row) {
|
||||
$row['new_user_id'] = $orderedUsers[$i % $n];
|
||||
}
|
||||
unset($row);
|
||||
$groups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$groups[$uid] = [];
|
||||
}
|
||||
foreach ($keep as $row) {
|
||||
$groups[$row['new_user_id']][] = $row;
|
||||
}
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$output->writeln('<info>user ' . $uid . ' 分配: ' . count($groups[$uid]) . ' 条</info>');
|
||||
}
|
||||
foreach (array_slice($keep, 0, 3) as $row) {
|
||||
$output->writeln('<comment>样例[行' . $row['excel_row'] . '] ' . $row['number'] . ' '
|
||||
. mb_substr($row['message'], 0, 20) . ' -> user ' . $row['new_user_id'] . '</comment>');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('<info>[dry-run] 不上传截图、不写库、不发邮件。确认后去掉 --dry-run 执行。</info>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$notifyOnly) {
|
||||
// 先传截图(外部操作,不在事务内;失败不阻断,仅记录),再事务建档
|
||||
$uploaded = 0;
|
||||
foreach ($keep as &$row) {
|
||||
$key = $this->uploadShot($imagesDir, $row['shot'], $output);
|
||||
$row['shot_key'] = $key;
|
||||
if ($key !== null) {
|
||||
$uploaded++;
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
$output->writeln('<info>截图已上传 OSS: ' . $uploaded . '</info>');
|
||||
|
||||
$created = [];
|
||||
Db::connection()->transaction(function () use ($keep, $output, &$created) {
|
||||
foreach ($keep as $row) {
|
||||
$desc = $row['message'];
|
||||
if ($row['shot'] !== '') {
|
||||
$desc .= ($desc !== '' ? "\n" : '') . '截图:' . ($row['shot_key'] ?? $row['shot']);
|
||||
}
|
||||
$c = new CrmCustomer();
|
||||
$c->corp_id = self::CORP_ID;
|
||||
$c->user_id = $row['new_user_id'];
|
||||
$c->type = self::TYPE;
|
||||
$c->channel_id = self::CHANNEL_ID;
|
||||
$c->grade = self::GRADE;
|
||||
$c->magnitude = self::MAGNITUDE;
|
||||
$c->email = '';
|
||||
$c->phone = '';
|
||||
$c->WhatsApp = $row['number'];
|
||||
$c->contacts = $row['number'];
|
||||
$c->desc_collect = $desc;
|
||||
$c->obtain = time();
|
||||
if (!$c->save()) {
|
||||
throw new \RuntimeException('行' . $row['excel_row'] . ' 建档失败');
|
||||
}
|
||||
$row['code'] = $c->code;
|
||||
$created[] = $row;
|
||||
}
|
||||
$output->writeln('<info>已建档并分配: ' . count($created) . ' 条(code 自动生成)</info>');
|
||||
});
|
||||
$keep = $created;
|
||||
$groups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$groups[$uid] = [];
|
||||
}
|
||||
foreach ($keep as $row) {
|
||||
$groups[$row['new_user_id']][] = $row;
|
||||
}
|
||||
} else {
|
||||
$output->writeln('<comment>[notify-only] 跳过上传与写库。</comment>');
|
||||
}
|
||||
|
||||
$manifestPath = $this->manifestPath($date);
|
||||
|
||||
if ($notifyOnly) {
|
||||
// 建档后号码已入库,重新解析会被去重清空——补发必须用 manifest 保存的分配结果
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)@file_get_contents($manifestPath), true) ?: []) : [];
|
||||
if (empty($manifest['groups'])) {
|
||||
$output->writeln('<error>manifest 不存在或无分配数据: ' . $manifestPath . ';请先执行 --no-notify 或完整命令生成</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$digestGroups = $manifest['groups'];
|
||||
} else {
|
||||
$totalRows = array_sum(array_map('count', $groups));
|
||||
if ($totalRows > 0) {
|
||||
$digestGroups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$digestGroups[(string)$uid] = [];
|
||||
}
|
||||
foreach ($groups as $uid => $rows) {
|
||||
foreach ($rows as $row) {
|
||||
$digestGroups[(string)$uid][] = $this->digestRow($row);
|
||||
}
|
||||
}
|
||||
$this->saveManifest($manifestPath, $targetUserIds, $digestGroups);
|
||||
} else {
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)@file_get_contents($manifestPath), true) ?: []) : [];
|
||||
if (!empty($manifest['groups'])) {
|
||||
$digestGroups = $manifest['groups'];
|
||||
$output->writeln('<comment>本次无新建档(可能已导入过),沿用已有 manifest 的分配结果。</comment>');
|
||||
} else {
|
||||
$output->writeln('<comment>没有可建档数据,结束。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
if ($noNotify) {
|
||||
$output->writeln('<comment>[no-notify] 已建档,跳过发邮件。核对库后用 --notify-only 补发。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendDigests($digestGroups, $targetUserIds, $manifestPath, $onlyUser, $output);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function saveManifest(string $path, array $targetUserIds, array $digestGroups): void
|
||||
{
|
||||
$perUser = []; $emailed = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$perUser[(string)$uid] = count($digestGroups[(string)$uid] ?? []);
|
||||
$emailed[(string)$uid] = false;
|
||||
}
|
||||
$data = [
|
||||
'date' => date('Y-m-d'),
|
||||
'imported_at' => time(),
|
||||
'target_users' => $targetUserIds,
|
||||
'per_user_counts' => $perUser,
|
||||
'groups' => $digestGroups,
|
||||
'emailed' => $emailed,
|
||||
];
|
||||
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* 建档行 -> 邮件汇总行(联系人/电话列显示 WhatsApp 号)
|
||||
*/
|
||||
private function digestRow(array $row): array
|
||||
{
|
||||
return [
|
||||
'code' => $row['code'] ?? '',
|
||||
'contact' => $row['number'],
|
||||
'email' => '',
|
||||
'phone' => $row['number'],
|
||||
'country_cn' => '',
|
||||
'channel' => self::CHANNEL_NAME,
|
||||
'remark' => $row['message'],
|
||||
];
|
||||
}
|
||||
|
||||
private function sendDigests(array $digestGroups, array $targetUserIds, string $manifestPath, int $onlyUser, OutputInterface $output): void
|
||||
{
|
||||
$users = Db::table('user')->whereIn('id', $targetUserIds)->get(['id', 'name', 'email']);
|
||||
$userMap = [];
|
||||
foreach ($users as $u) {
|
||||
$userMap[(int)$u->id] = $u;
|
||||
}
|
||||
$manifest = is_file($manifestPath) ? (json_decode((string)file_get_contents($manifestPath), true) ?: ['emailed' => []]) : ['emailed' => []];
|
||||
$date = date('Y-m-d');
|
||||
|
||||
foreach ($targetUserIds as $uid) {
|
||||
if (count($digestGroups[(string)$uid] ?? []) === 0) {
|
||||
$output->writeln('<comment>user ' . $uid . ' 未分到询盘,不发送</comment>');
|
||||
continue;
|
||||
}
|
||||
if ($onlyUser > 0 && $uid !== $onlyUser) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($manifest['emailed'][(string)$uid])) {
|
||||
$output->writeln('<comment>跳过已发送: user ' . $uid . '</comment>');
|
||||
continue;
|
||||
}
|
||||
$u = $userMap[$uid] ?? null;
|
||||
if (!$u || empty($u->email)) {
|
||||
$output->writeln('<error>user ' . $uid . ' 无邮箱,跳过</error>');
|
||||
continue;
|
||||
}
|
||||
$inquiries = array_values($digestGroups[(string)$uid] ?? []);
|
||||
$html = InquiryDigestMail::build((string)$u->name, $inquiries, $date);
|
||||
$subject = 'WhatsApp新询盘分配汇总(共 ' . count($inquiries) . ' 条)- ' . $u->name . ' - ' . $date;
|
||||
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
if (!$ok) {
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
}
|
||||
$manifest['emailed'][(string)$uid] = (bool)$ok;
|
||||
file_put_contents($manifestPath, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
|
||||
$tag = $ok ? 'info' : 'error';
|
||||
$output->writeln('<' . $tag . '>user ' . $uid . ' (' . $u->email . '): '
|
||||
. ($ok ? '发送成功' : '发送失败') . ',共 ' . count($inquiries) . ' 条</' . $tag . '>');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\utils\email\InquiryDigestMail;
|
||||
use app\common\utils\email\MailSend;
|
||||
use app\crm\model\CustomerHis;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use support\Db;
|
||||
|
||||
class InquiryBulkAssignCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'inquiry:bulk-assign';
|
||||
protected static $defaultDescription = '按 Excel 行序轮询分配询盘给业务员,更新 crm_customer.user_id 并发汇总邮件';
|
||||
|
||||
// Excel 列映射(A=编号/code)
|
||||
private const COL_CODE = 'A';
|
||||
private const COL_CHANNEL = 'D';
|
||||
private const COL_EMAIL = 'E';
|
||||
private const COL_PHONE = 'F';
|
||||
private const COL_WHATSAPP = 'G';
|
||||
private const COL_CONTACT = 'H';
|
||||
private const COL_REMARK = 'I';
|
||||
private const COL_COUNTRY_CN = 'J';
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Excel 文件路径', 'docs/崔琪2.xlsx')
|
||||
->addOption('users', null, InputOption::VALUE_REQUIRED, '目标 user_id 列表,逗号分隔', '34,73,72,155,64,211')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, '只解析+匹配+分配,打印报告,不写库不发邮件')
|
||||
->addOption('only-user', null, InputOption::VALUE_REQUIRED, '仅给指定 user_id 发邮件(试发)', '0')
|
||||
->addOption('notify-only', null, InputOption::VALUE_NONE, '跳过写库,按 manifest 补发未成功的邮件')
|
||||
->addOption('no-notify', null, InputOption::VALUE_NONE, '只写库不发邮件(先核对库再用 --notify-only 补发)')
|
||||
->addOption('self-test', null, InputOption::VALUE_NONE, '运行纯逻辑自测');
|
||||
}
|
||||
|
||||
/**
|
||||
* 内存轮询分配(确定性 i % n)
|
||||
* 给 $matched 每项追加 new_user_id,返回 [user_id => [rows...]]
|
||||
*/
|
||||
public static function assign(array &$matched, array $targetUserIds): array
|
||||
{
|
||||
$groups = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$groups[$uid] = [];
|
||||
}
|
||||
$n = count($targetUserIds);
|
||||
foreach ($matched as $i => &$row) {
|
||||
$uid = $targetUserIds[$i % $n];
|
||||
$row['new_user_id'] = $uid;
|
||||
$groups[$uid][] = $row;
|
||||
}
|
||||
unset($row);
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Excel,按行顺序返回询盘数组
|
||||
*/
|
||||
private function parseExcel(string $filePath): array
|
||||
{
|
||||
$sheet = IOFactory::load($filePath)->getActiveSheet();
|
||||
$highestRow = $sheet->getHighestRow();
|
||||
$rows = [];
|
||||
$get = function (string $col, int $r) use ($sheet): string {
|
||||
return trim((string)($sheet->getCell($col . $r)->getValue() ?? ''));
|
||||
};
|
||||
for ($r = 2; $r <= $highestRow; $r++) {
|
||||
$code = $get(self::COL_CODE, $r);
|
||||
if ($code === '') {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'code' => $code,
|
||||
'channel' => $get(self::COL_CHANNEL, $r),
|
||||
'email' => $get(self::COL_EMAIL, $r),
|
||||
'phone' => $get(self::COL_PHONE, $r),
|
||||
'whatsapp' => $get(self::COL_WHATSAPP, $r),
|
||||
'contact' => $get(self::COL_CONTACT, $r),
|
||||
'country_cn' => $get(self::COL_COUNTRY_CN, $r),
|
||||
'remark' => $get(self::COL_REMARK, $r),
|
||||
'excel_row' => $r,
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量匹配 crm_customer(code 唯一取 MIN(id),排除软删除)
|
||||
* 返回 ['matched' => [...], 'unmatched' => [...]];matched 每项追加 customer_id/old_user_id
|
||||
*/
|
||||
private function matchCustomers(array $rows): array
|
||||
{
|
||||
$codes = array_values(array_unique(array_column($rows, 'code')));
|
||||
$map = []; // code => ['id','old_user_id']
|
||||
foreach (array_chunk($codes, 500) as $chunk) {
|
||||
$recs = Db::table('crm_customer')
|
||||
->whereIn('code', $chunk)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('id', 'asc')
|
||||
->get(['id', 'code', 'user_id']);
|
||||
foreach ($recs as $rec) {
|
||||
if (!isset($map[$rec->code])) {
|
||||
$map[$rec->code] = ['id' => (int)$rec->id, 'old_user_id' => $rec->user_id];
|
||||
}
|
||||
}
|
||||
}
|
||||
$matched = [];
|
||||
$unmatched = [];
|
||||
foreach ($rows as $row) {
|
||||
if (isset($map[$row['code']])) {
|
||||
$row['customer_id'] = $map[$row['code']]['id'];
|
||||
$row['old_user_id'] = $map[$row['code']]['old_user_id'];
|
||||
$matched[] = $row;
|
||||
} else {
|
||||
$unmatched[] = $row;
|
||||
}
|
||||
}
|
||||
return ['matched' => $matched, 'unmatched' => $unmatched];
|
||||
}
|
||||
|
||||
/**
|
||||
* 单事务:更新 user_id + 写历史。用 Db::table() 不触发 model 事件。
|
||||
*/
|
||||
private function applyAssignments(array $matched): void
|
||||
{
|
||||
Db::connection()->transaction(function () use ($matched) {
|
||||
foreach ($matched as $row) {
|
||||
Db::table('crm_customer')
|
||||
->where('id', $row['customer_id'])
|
||||
->update(['user_id' => $row['new_user_id']]);
|
||||
CustomerHis::create([
|
||||
'customer_id' => $row['customer_id'],
|
||||
'user_his' => $row['old_user_id'],
|
||||
'user_id' => $row['new_user_id'],
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function manifestPath(string $date): string
|
||||
{
|
||||
return runtime_path() . '/inquiry_bulk_assign_' . $date . '.json';
|
||||
}
|
||||
|
||||
private function initManifest(string $path, array $targetUserIds, array $matched, array $groups): void
|
||||
{
|
||||
if (is_file($path)) {
|
||||
return; // 已存在则保留(含 emailed 标记),供补发使用
|
||||
}
|
||||
$perUser = [];
|
||||
$emailed = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$perUser[(string)$uid] = count($groups[$uid] ?? []);
|
||||
$emailed[(string)$uid] = false;
|
||||
}
|
||||
$data = [
|
||||
'date' => date('Y-m-d'),
|
||||
'assigned_at' => time(),
|
||||
'target_users' => $targetUserIds,
|
||||
'total_matched' => count($matched),
|
||||
'per_user_counts' => $perUser,
|
||||
'emailed' => $emailed,
|
||||
];
|
||||
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
private function loadManifest(string $path): array
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return ['emailed' => []];
|
||||
}
|
||||
return json_decode((string)file_get_contents($path), true) ?: ['emailed' => []];
|
||||
}
|
||||
|
||||
private function saveManifest(string $path, array $data): void
|
||||
{
|
||||
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 targetUserIds 顺序发汇总邮件;跳过已标记成功与 --only-user 之外的人。
|
||||
*/
|
||||
private function sendDigests(array $groups, array $targetUserIds, string $manifestPath, int $onlyUser, OutputInterface $output): void
|
||||
{
|
||||
$users = Db::table('user')->whereIn('id', $targetUserIds)->get(['id', 'name', 'email']);
|
||||
$userMap = [];
|
||||
foreach ($users as $u) {
|
||||
$userMap[(int)$u->id] = $u;
|
||||
}
|
||||
$manifest = $this->loadManifest($manifestPath);
|
||||
$date = date('Y-m-d');
|
||||
|
||||
foreach ($targetUserIds as $uid) {
|
||||
if ($onlyUser > 0 && $uid !== $onlyUser) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($manifest['emailed'][(string)$uid])) {
|
||||
$output->writeln('<comment>跳过已发送: user ' . $uid . '</comment>');
|
||||
continue;
|
||||
}
|
||||
$u = $userMap[$uid] ?? null;
|
||||
if (!$u || empty($u->email)) {
|
||||
$output->writeln('<error>user ' . $uid . ' 无邮箱,跳过</error>');
|
||||
continue;
|
||||
}
|
||||
$inquiries = $groups[$uid] ?? [];
|
||||
$html = InquiryDigestMail::build((string)$u->name, $inquiries, $date);
|
||||
$subject = '询盘分配汇总(共 ' . count($inquiries) . ' 条)- ' . $u->name . ' - ' . $date;
|
||||
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
if (!$ok) {
|
||||
$ok = MailSend::send($u->email, $subject, $html); // 重试 1 次
|
||||
}
|
||||
$manifest['emailed'][(string)$uid] = (bool)$ok;
|
||||
$this->saveManifest($manifestPath, $manifest);
|
||||
|
||||
$tag = $ok ? 'info' : 'error';
|
||||
$output->writeln('<' . $tag . '>user ' . $uid . ' (' . $u->email . '): '
|
||||
. ($ok ? '发送成功' : '发送失败') . ',共 ' . count($inquiries) . ' 条</' . $tag . '>');
|
||||
}
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
if ($input->getOption('self-test')) {
|
||||
return $this->runSelfTest($output);
|
||||
}
|
||||
|
||||
$file = $input->getOption('file');
|
||||
$targetUserIds = array_values(array_filter(array_map('intval', explode(',', (string)$input->getOption('users')))));
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$notifyOnly = (bool)$input->getOption('notify-only');
|
||||
$noNotify = (bool)$input->getOption('no-notify');
|
||||
$onlyUser = (int)$input->getOption('only-user');
|
||||
$date = date('Y-m-d');
|
||||
|
||||
if (!$targetUserIds) {
|
||||
$output->writeln('<error>--users 不能为空</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (!is_file($file)) {
|
||||
$output->writeln('<error>文件不存在: ' . $file . '</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($notifyOnly && $noNotify) {
|
||||
$output->writeln('<error>--notify-only 与 --no-notify 不能同时使用</error>');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$output->writeln('<info>解析 Excel: ' . $file . '</info>');
|
||||
$rows = $this->parseExcel($file);
|
||||
$output->writeln('<info>Excel 询盘数: ' . count($rows) . '</info>');
|
||||
|
||||
$res = $this->matchCustomers($rows);
|
||||
$matched = $res['matched'];
|
||||
$unmatched = $res['unmatched'];
|
||||
$output->writeln('<info>匹配成功: ' . count($matched) . ',未匹配: ' . count($unmatched) . '</info>');
|
||||
if ($unmatched) {
|
||||
$sample = implode(', ', array_slice(array_column($unmatched, 'code'), 0, 10));
|
||||
$output->writeln('<comment>未匹配 code 样例: ' . $sample . '</comment>');
|
||||
}
|
||||
|
||||
$groups = self::assign($matched, $targetUserIds);
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$output->writeln('<info>user ' . $uid . ' 分配: ' . count($groups[$uid]) . ' 条</info>');
|
||||
}
|
||||
foreach ($matched as $i => $row) {
|
||||
if ($i < 3) {
|
||||
$output->writeln('<comment>样例[' . $i . '] code=' . $row['code']
|
||||
. ' -> user ' . $row['new_user_id'] . ' (原 ' . $row['old_user_id'] . ')</comment>');
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('<info>[dry-run] 不写库、不发邮件。确认无误后去掉 --dry-run 执行。</info>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$notifyOnly) {
|
||||
$this->applyAssignments($matched);
|
||||
$output->writeln('<info>已写库(user_id 更新 + crm_customer_his),共 ' . count($matched) . ' 条。</info>');
|
||||
} else {
|
||||
$output->writeln('<comment>[notify-only] 跳过写库。</comment>');
|
||||
}
|
||||
|
||||
if ($noNotify) {
|
||||
$output->writeln('<comment>[no-notify] 已写库,跳过发邮件。核对库后用 --notify-only 补发。</comment>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$manifestPath = $this->manifestPath($date);
|
||||
$this->initManifest($manifestPath, $targetUserIds, $matched, $groups);
|
||||
$this->sendDigests($groups, $targetUserIds, $manifestPath, $onlyUser, $output);
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function runSelfTest(OutputInterface $output): int
|
||||
{
|
||||
$fail = 0;
|
||||
|
||||
// 1) assign: 7 条 / 3 人 -> 计数 [3,2,2]
|
||||
$items = array_fill(0, 7, []);
|
||||
$groups = self::assign($items, [10, 20, 30]);
|
||||
$counts = [count($groups[10]), count($groups[20]), count($groups[30])];
|
||||
if ($counts !== [3, 2, 2]) {
|
||||
$output->writeln('<error>assign 计数错误: ' . json_encode($counts) . '</error>');
|
||||
$fail++;
|
||||
}
|
||||
|
||||
// 2) InquiryDigestMail::build 内容校验
|
||||
$html = InquiryDigestMail::build('张三', [[
|
||||
'code' => 'CM1', 'contact' => 'A', 'email' => 'a@x.com',
|
||||
'phone' => '1', 'country_cn' => '中国', 'channel' => 'fb', 'remark' => '意向车型:X',
|
||||
]], '2026-07-17');
|
||||
if (strpos($html, '张三') === false || strpos($html, 'CM1') === false
|
||||
|| strpos($html, '共 <strong>1</strong> 条') === false) {
|
||||
$output->writeln('<error>InquiryDigestMail::build 内容校验失败</error>');
|
||||
$fail++;
|
||||
}
|
||||
|
||||
if ($fail === 0) {
|
||||
$output->writeln('<info>self-test 通过</info>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace app\common\controller;
|
||||
|
||||
use app\common\service\AuthService;
|
||||
use app\common\validate\UserValidate;
|
||||
use Exception;
|
||||
use support\Request;
|
||||
|
||||
class AuthController extends BaseController
|
||||
@@ -31,7 +32,8 @@ class AuthController extends BaseController
|
||||
public function feishuScope(Request $request)
|
||||
{
|
||||
try {
|
||||
$result = (new AuthService())->feishuAuthorize($request->corp_abbr, $request->system_code);
|
||||
$is_mob = $request->get('is_mob', 0);
|
||||
$result = (new AuthService())->feishuAuthorize($request->corp_abbr, $request->system_code, $is_mob);
|
||||
return $this->success($result);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
@@ -112,4 +114,19 @@ class AuthController extends BaseController
|
||||
// (new AuthService())->blockToken($token, $systemCode);
|
||||
// return $this->success([], '已强制停用该token');
|
||||
// }
|
||||
|
||||
|
||||
public function getJsApi(Request $request)
|
||||
{
|
||||
try {
|
||||
$url = $request->get('url');
|
||||
if(empty($url)){
|
||||
throw new Exception("url不能为空");
|
||||
}
|
||||
$result = (new AuthService())->getJsApiConfig($request->corp_abbr, $request->system_code,$url);
|
||||
return $this->success($result);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\InvRequisitionApproveService;
|
||||
|
||||
class InvRequisitionApproveController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InvRequisitionApproveService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
// protected function indexFormat($data)
|
||||
// {
|
||||
// if (isset($data['created_at']))
|
||||
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
// if (isset($data['updated_at']))
|
||||
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
|
||||
// return $data;
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\InvRequisitionAttachmentService;
|
||||
|
||||
class InvRequisitionAttachmentController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InvRequisitionAttachmentService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
// protected function indexFormat($data)
|
||||
// {
|
||||
// if (isset($data['created_at']))
|
||||
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
// if (isset($data['updated_at']))
|
||||
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
|
||||
// return $data;
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\InvRequisitionItemService;
|
||||
|
||||
class InvRequisitionItemController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InvRequisitionItemService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
// protected function indexFormat($data)
|
||||
// {
|
||||
// if (isset($data['created_at']))
|
||||
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
// if (isset($data['updated_at']))
|
||||
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
|
||||
// return $data;
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\InvRequisitionItemReturnAttachmentService;
|
||||
|
||||
class InvRequisitionItemReturnAttachmentController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InvRequisitionItemReturnAttachmentService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
// protected function indexFormat($data)
|
||||
// {
|
||||
// if (isset($data['created_at']))
|
||||
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
// if (isset($data['updated_at']))
|
||||
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
|
||||
// return $data;
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\model\InvItem;
|
||||
use app\common\model\InvRequisitionItem;
|
||||
use app\common\model\InvRequisitionItemReturn;
|
||||
use app\common\model\InvRequisitionItemReturnAttachment;
|
||||
use app\common\service\OssService;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\InvRequisitionItemReturnService;
|
||||
use support\Request;
|
||||
|
||||
class InvRequisitionItemReturnController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InvRequisitionItemReturnService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return mixed 返回分页数据及总数
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
try {
|
||||
//$request->setGet('$request');
|
||||
$raw = $this->service->serviceList($request);
|
||||
$data = $this->transformData($raw, method_exists($this, 'indexFormat')
|
||||
? [$this, 'indexFormat']
|
||||
: null);
|
||||
return $this->success($data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
protected function indexFormat($data)
|
||||
{
|
||||
if(isset($data['id'])){
|
||||
$data['return_item'] = InvRequisitionItemReturn::where('return_id', $data['id'])->get();
|
||||
foreach ($data['return_item'] as $key => $value) {
|
||||
$data['return_item'][$key]['item'] = InvItem::where('id', $value['item_id'])->get();
|
||||
}
|
||||
}
|
||||
if (isset($data['return_at']))
|
||||
$data['return_at'] = date('Y-m-d H:i:s', $data['return_at']);
|
||||
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 请求对象
|
||||
* @param int|string $id 资源 ID
|
||||
* @return mixed 返回资源详情数据
|
||||
*/
|
||||
public function show(Request $request)
|
||||
{
|
||||
try {
|
||||
$raw = $this->service->serviceShow($request);
|
||||
$data = $this->transformData($raw, method_exists($this, 'showFormat')
|
||||
? [$this, 'showFormat']
|
||||
: null);
|
||||
return $this->success($data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
protected function showFormat($data)
|
||||
{
|
||||
foreach ($data['item_return'] as $key => $item){
|
||||
$data['item_return'][$key]['requisition_item'] = InvRequisitionItem::where('id', $item['requisition_item_id'])->first();
|
||||
}
|
||||
if (isset($data['return_at']))
|
||||
$data['return_at'] = date('Y-m-d H:i:s', $data['return_at']);
|
||||
if (isset($data['id'])){
|
||||
//归还附件
|
||||
$data['return_attachment'] = InvRequisitionItemReturnAttachment::where('return_id', $data['id'])->get();
|
||||
$ossservice = new OssService();
|
||||
foreach ($data['return_attachment'] as $item){
|
||||
$item['file_path_pull'] = $ossservice->generatePrivateUrl($item['file_path']);
|
||||
|
||||
}
|
||||
|
||||
//归还审核附件
|
||||
$data['return_approve_attachment'] = InvRequisitionItemReturnAttachment::where('return_id', $data['id'])->get();
|
||||
foreach ($data['return_approve_attachment'] as $item){
|
||||
$item['file_path_pull'] = $ossservice->generatePrivateUrl($item['file_path']);
|
||||
}
|
||||
}
|
||||
/*if (isset($data['created_at']))
|
||||
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
if (isset($data['updated_at']))
|
||||
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);*/
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 更新
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return mixed 返回更新成功的数据或信息
|
||||
*/
|
||||
public function update(Request $request)
|
||||
{
|
||||
try {
|
||||
$data = $this->validate($this->service->getValidator(), $request->post(), 'update');
|
||||
$res = $this->service->serviceUpdate($request, $data);
|
||||
return $this->success([], '更新成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 物品归还确认
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function returnItemConfirm(Request $request)
|
||||
{
|
||||
try {
|
||||
// 验证申请数据
|
||||
$data = $this->validate($this->service->getValidator(), $request->post(), 'returnConfirm');
|
||||
// 处理申请逻辑
|
||||
$result = $this->service->serviceReturnItemConfirm($request, $data, 'returnConfirm');
|
||||
|
||||
return $this->success($result, '申请提交成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 物品归还确认
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function returnConfirm(Request $request)
|
||||
{
|
||||
try {
|
||||
// 验证申请数据
|
||||
$data = $this->validate($this->service->getValidator(), $request->post(), 'returnConfirm');
|
||||
|
||||
// 处理申请逻辑
|
||||
$result = $this->service->serviceReturnConfirm($request, $data, 'returnConfirm');
|
||||
|
||||
return $this->success($result, '申请提交成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\InvRequisitionReturnService;
|
||||
|
||||
class InvRequisitionReturnController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InvRequisitionReturnService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @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;
|
||||
// }
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\model\StockOrders;
|
||||
use app\common\model\StockOrderItems;
|
||||
use app\common\model\User;
|
||||
use support\Request;
|
||||
|
||||
class PublicApiController extends BaseController
|
||||
{
|
||||
// 业务人员固定角色ID
|
||||
protected const STAFF_ROLE_ID = 2;
|
||||
|
||||
protected $noNeedLogin = ['piList', 'vinList', 'staffList'];
|
||||
protected $noNeedHeaderData = ['piList', 'vinList', 'staffList'];
|
||||
|
||||
/**
|
||||
* PI 号列表(含关联的 VIN 和车型名称)
|
||||
* GET /common/public/pi/list
|
||||
*/
|
||||
public function piList(Request $request)
|
||||
{
|
||||
try {
|
||||
$page = (int)($request->input('page', 1));
|
||||
$pageSize = (int)($request->input('page_size', 20));
|
||||
$piNo = $request->input('pi_no', '');
|
||||
|
||||
if ($page < 1) $page = 1;
|
||||
if ($pageSize < 1 || $pageSize > 200) $pageSize = 20;
|
||||
|
||||
$query = StockOrders::where('approval_status', 2)
|
||||
->whereNotNull('pi_no')
|
||||
->where('pi_no', '<>', '');
|
||||
|
||||
if ($piNo !== '') {
|
||||
$query->where('pi_no', 'like', '%' . addcslashes($piNo, '%_') . '%');
|
||||
}
|
||||
|
||||
$query->with(['items' => function ($q) {
|
||||
$q->select('id', 'order_id', 'vin', 'model_snapshot');
|
||||
}]);
|
||||
|
||||
$paginator = $query->orderBy('id', 'desc')
|
||||
->paginate($pageSize, ['id', 'pi_no'], 'page', $page);
|
||||
|
||||
$data = $paginator->getCollection()->map(function ($order) {
|
||||
return [
|
||||
'pi_no' => $order->pi_no,
|
||||
'vins' => $order->items->pluck('vin')->filter()->values()->toArray(),
|
||||
'model_names' => $order->items->pluck('model_snapshot')->filter()->values()->toArray(),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return $this->success([
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'data' => $data,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('查询失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VIN 号列表(含车型名称)
|
||||
* GET /common/public/vin/list
|
||||
*/
|
||||
public function vinList(Request $request)
|
||||
{
|
||||
try {
|
||||
$page = (int)($request->input('page', 1));
|
||||
$pageSize = (int)($request->input('page_size', 20));
|
||||
$vin = $request->input('vin', '');
|
||||
|
||||
if ($page < 1) $page = 1;
|
||||
if ($pageSize < 1 || $pageSize > 200) $pageSize = 20;
|
||||
|
||||
$query = StockOrderItems::whereHas('order', function ($q) {
|
||||
$q->where('approval_status', 2);
|
||||
})->whereNotNull('vin')
|
||||
->where('vin', '<>', '');
|
||||
|
||||
if ($vin !== '') {
|
||||
$query->where('vin', 'like', '%' . addcslashes($vin, '%_') . '%');
|
||||
}
|
||||
|
||||
$paginator = $query->orderBy('id', 'desc')
|
||||
->paginate($pageSize, ['id', 'vin', 'model_snapshot'], 'page', $page);
|
||||
|
||||
$data = $paginator->getCollection()->map(function ($item) {
|
||||
return [
|
||||
'vin' => $item->vin,
|
||||
'model_name' => $item->model_snapshot,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return $this->success([
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'data' => $data,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('查询失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务人员列表(role_id=2,在职,全量返回)
|
||||
* GET /common/public/staff/list
|
||||
*/
|
||||
public function staffList(Request $request)
|
||||
{
|
||||
try {
|
||||
$keyword = $request->input('keyword', '');
|
||||
|
||||
// 通过 user_roles 关联,筛选指定角色的用户
|
||||
$query = User::whereHas('roles', function ($q) {
|
||||
$q->where('roles.id', static::STAFF_ROLE_ID);
|
||||
})->where('status', 1);
|
||||
|
||||
if ($keyword !== '') {
|
||||
$kw = '%' . addcslashes($keyword, '%_') . '%';
|
||||
$query->where(function ($q) use ($kw) {
|
||||
$q->where('name', 'like', $kw)
|
||||
->orWhere('sn', 'like', $kw)
|
||||
->orWhere('phone', 'like', $kw)
|
||||
->orWhere('email', 'like', $kw);
|
||||
});
|
||||
}
|
||||
|
||||
$list = $query->orderBy('id', 'asc')
|
||||
->get(['id', 'sn', 'name', 'phone', 'email']);
|
||||
|
||||
$data = $list->map(function ($user) {
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'sn' => $user->sn,
|
||||
'phone' => $user->phone,
|
||||
'email' => $user->email,
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
return $this->success($data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('查询失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\traits\ResponseTrait;
|
||||
use support\Request;
|
||||
use support\Response;
|
||||
use app\common\utils\SystemAuth;
|
||||
use app\common\utils\Jwt;
|
||||
use app\common\model\User;
|
||||
use app\common\model\Systems;
|
||||
use support\Redis;
|
||||
|
||||
class SystemController
|
||||
{
|
||||
use ResponseTrait;
|
||||
|
||||
protected $noNeedLogin = ['auth'];
|
||||
|
||||
protected $noNeedHeaderData = ['auth'];
|
||||
/**
|
||||
* 系统间认证接口
|
||||
* POST /api/system/auth
|
||||
*/
|
||||
public function auth(Request $request): Response
|
||||
{
|
||||
$userId = $request->post('user_id');
|
||||
$timestamp = (int)$request->post('timestamp');
|
||||
$signature = $request->post('signature');
|
||||
$systemCode = $request->post('system_code');
|
||||
|
||||
// 参数验证
|
||||
if (empty($userId) || empty($timestamp) || empty($signature)) {
|
||||
return $this->error('参数不完整', 400);
|
||||
}
|
||||
|
||||
// 获取配置
|
||||
$secret = config('system_auth.shared_secret');
|
||||
$expire = config('system_auth.signature_expire', 600);
|
||||
|
||||
// 验证时间戳(10分钟有效期)
|
||||
if (!SystemAuth::verifyTimestamp($timestamp, $expire)) {
|
||||
return $this->error('请求已过期', 401);
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
if (!SystemAuth::verifySignature($userId, $timestamp, $signature, $secret)) {
|
||||
return $this->error('签名验证失败', 401);
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
$user = User::find($userId);
|
||||
if (!$user) {
|
||||
return $this->error('用户不存在', 404);
|
||||
}
|
||||
|
||||
// 验证系统(如果提供了 system_code)
|
||||
$system = null;
|
||||
if ($systemCode) {
|
||||
$system = Systems::where('code', $systemCode)->first();
|
||||
if (!$system) {
|
||||
return $this->error('系统不存在', 404);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 corp_abbr(优先从请求参数获取,否则通过用户 corp_id 查询)
|
||||
$corpAbbr = $request->post('corp_abbr');
|
||||
if (!$corpAbbr && $user->corp_id) {
|
||||
$corp = \app\common\model\Corp::find($user->corp_id);
|
||||
$corpAbbr = $corp ? $corp->abbr : '';
|
||||
}
|
||||
|
||||
// 构建 JWT payload
|
||||
$payload = $this->buildJwtPayload($user, $system);
|
||||
|
||||
// 生成 access_token
|
||||
$jwt = new Jwt();
|
||||
$token = $jwt->generateToken($payload);
|
||||
|
||||
// 生成 refresh_token
|
||||
$refreshToken = $this->generateRefreshToken($user->id, $systemCode ?? '');
|
||||
|
||||
// 获取 home_url
|
||||
$homeUrl = $system ? $this->getHome($system->id, $payload) : '';
|
||||
|
||||
// 记录 token 到 Redis 和 MySQL
|
||||
$this->recordUserToken($user->id, $token, $systemCode ?? '');
|
||||
|
||||
// 构建用户信息(去除敏感信息)
|
||||
$userInfo = [
|
||||
'id' => $user->id,
|
||||
'sn' => $user->sn ?? '',
|
||||
'name' => $user->name ?? '',
|
||||
'avatar' => $user->avatar ?? '',
|
||||
'phone' => $user->phone ?? '',
|
||||
'email' => $user->email ?? '',
|
||||
'sex' => $user->sex ?? '',
|
||||
'position' => $user->position ?? '',
|
||||
'status' => $user->status ?? 1,
|
||||
'corp_id' => $user->corp_id,
|
||||
];
|
||||
|
||||
return $this->success([
|
||||
'access_token' => $token,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_in' => config('jwt.jwt_expiration'),
|
||||
'system_code' => $systemCode ?? '',
|
||||
'corp_abbr' => $corpAbbr ?? '',
|
||||
'login_type' => 'system',
|
||||
'home_url' => $homeUrl,
|
||||
'user_info' => $userInfo,
|
||||
], '认证成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 JWT payload
|
||||
*/
|
||||
private function buildJwtPayload(User $user, ?Systems $system): array
|
||||
{
|
||||
$now = time();
|
||||
$exp = $now + config('jwt.jwt_expiration');
|
||||
$system_ids = $user->systems ? $user->systems->pluck('id')->toArray() : [];
|
||||
$role_ids = $user->roles ? $user->roles->pluck('id')->toArray() : [];
|
||||
$payload = [
|
||||
'sub' => 'user_' . $user->id,
|
||||
'aud' => $system->code,
|
||||
'iat' => $now,
|
||||
'exp' => $exp,
|
||||
'nbf' => $now,
|
||||
'typ' => 'access',
|
||||
'uid' => $user->id,
|
||||
'uname' => $user->name,
|
||||
'role_ids' => $role_ids,
|
||||
'system_ids' => $system_ids,
|
||||
// 'user_id' => $user->id,
|
||||
// 'sn' => $user->sn ?? '',
|
||||
// 'name' => $user->name ?? '',
|
||||
];
|
||||
|
||||
if ($system) {
|
||||
$payload['system_code'] = $system->code;
|
||||
$payload['system_id'] = $system->id;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 refresh_token
|
||||
*/
|
||||
private function generateRefreshToken(int $userId, string $systemCode): string
|
||||
{
|
||||
$refreshToken = bin2hex(random_bytes(32));
|
||||
|
||||
// 存储到 Redis
|
||||
$key = "refresh_token:{$refreshToken}";
|
||||
$expire = config('jwt.jwt_refresh_token_expiration');
|
||||
Redis::setex($key, $expire, json_encode([
|
||||
'user_id' => $userId,
|
||||
'system_code' => $systemCode,
|
||||
]));
|
||||
|
||||
return $refreshToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户首页地址
|
||||
*/
|
||||
private function getHome(int $systemId, array $payload): string
|
||||
{
|
||||
// 简单实现,可以根据实际需求调整
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录用户token到 Redis 和 MySQL
|
||||
*/
|
||||
private function recordUserToken(int $userId, string $token, string $systemCode): void
|
||||
{
|
||||
$request = request();
|
||||
$ip = $request ? $request->getRealIp() : '';
|
||||
$ua = $request ? $request->header('user-agent', '') : '';
|
||||
$now = time();
|
||||
$expireAt = $now + config('jwt.jwt_expiration');
|
||||
|
||||
// Redis Set 用户token集合
|
||||
Redis::sAdd("user_tokens:{$userId}:{$systemCode}", $token);
|
||||
Redis::expire("user_tokens:{$userId}:{$systemCode}", config('jwt.jwt_expiration'));
|
||||
|
||||
// Redis Hash token元信息
|
||||
$meta = [
|
||||
'system_code' => $systemCode,
|
||||
'login_ip' => $ip,
|
||||
'user_agent' => $ua,
|
||||
'login_time' => $now,
|
||||
'expired_at' => $expireAt,
|
||||
];
|
||||
Redis::hMSet("user_token_meta:{$token}", $meta);
|
||||
Redis::expire("user_token_meta:{$token}", config('jwt.jwt_expiration'));
|
||||
|
||||
// MySQL 持久化
|
||||
\app\common\model\UserTokenLog::create([
|
||||
'user_id' => $userId,
|
||||
'token' => $token,
|
||||
'system_code' => $systemCode,
|
||||
'login_ip' => $ip,
|
||||
'user_agent' => $ua,
|
||||
'expired_at' => $expireAt,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,15 @@ class AuthLogic
|
||||
{
|
||||
$certificate = $this->feishuAppInfo($data['corp_abbr'], $data['system_code']);
|
||||
$feishu = new Feishu($certificate['app_id'], $certificate['app_secret'], $certificate['abbr']);
|
||||
$userAccessToken = $feishu->userAccessToken($data['feishu_code'], $certificate['r_url']);
|
||||
|
||||
//判断移动端或pc端回调地址
|
||||
if(!empty($data['is_mob']) && $data['is_mob'] == 1){
|
||||
$redirect_uri = $certificate['m_url'];
|
||||
}else{
|
||||
$redirect_uri = $certificate['r_url'];
|
||||
}
|
||||
|
||||
$userAccessToken = $feishu->userAccessToken($data['feishu_code'], $redirect_uri);
|
||||
$userInfo = $feishu->userInfo($userAccessToken['access_token']);
|
||||
|
||||
if ($userInfo['code'] != 0) {
|
||||
@@ -298,6 +306,7 @@ class AuthLogic
|
||||
'app_id' => $fsAppInfo->app_id,
|
||||
'app_secret' => $fsAppInfo->app_secret,
|
||||
'r_url' => $fsAppInfo->r_url,
|
||||
'm_url' => $fsAppInfo->m_url,
|
||||
'scope' => $fsAppInfo->scopes,
|
||||
'state' => $fsAppInfo->state,
|
||||
'abbr' => $corpAbbr,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\logic;
|
||||
|
||||
use app\common\traits\crud\CrudLogicTrait;
|
||||
use app\common\model\InvItem;
|
||||
|
||||
class InvItemLogic
|
||||
{
|
||||
use CrudLogicTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvItem::class;
|
||||
|
||||
|
||||
public function getList()
|
||||
{
|
||||
return InvItem::where('status',1)->orderBy('sort','desc')->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\logic;
|
||||
|
||||
use app\common\traits\crud\CrudLogicTrait;
|
||||
use app\common\model\InvRequisitionApprove;
|
||||
|
||||
class InvRequisitionApproveLogic
|
||||
{
|
||||
use CrudLogicTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisitionApprove::class;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\logic;
|
||||
|
||||
use app\common\traits\crud\CrudLogicTrait;
|
||||
use app\common\model\InvRequisitionAttachment;
|
||||
|
||||
class InvRequisitionAttachmentLogic
|
||||
{
|
||||
use CrudLogicTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisitionAttachment::class;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\logic;
|
||||
|
||||
use app\common\traits\crud\CrudLogicTrait;
|
||||
use app\common\model\InvRequisitionItem;
|
||||
|
||||
class InvRequisitionItemLogic
|
||||
{
|
||||
use CrudLogicTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisitionItem::class;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\logic;
|
||||
|
||||
use app\common\traits\crud\CrudLogicTrait;
|
||||
use app\common\model\InvRequisitionItemReturnAttachment;
|
||||
|
||||
class InvRequisitionItemReturnAttachmentLogic
|
||||
{
|
||||
use CrudLogicTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisitionItemReturnAttachment::class;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\logic;
|
||||
|
||||
use app\common\traits\crud\CrudLogicTrait;
|
||||
use app\common\model\InvRequisitionItemReturn;
|
||||
|
||||
class InvRequisitionItemReturnLogic
|
||||
{
|
||||
use CrudLogicTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisitionItemReturn::class;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\logic;
|
||||
|
||||
use app\common\traits\crud\CrudLogicTrait;
|
||||
use app\common\model\InvRequisition;
|
||||
|
||||
class InvRequisitionLogic
|
||||
{
|
||||
use CrudLogicTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisition::class;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\logic;
|
||||
|
||||
use app\common\traits\crud\CrudLogicTrait;
|
||||
use app\common\model\InvRequisitionReturn;
|
||||
|
||||
class InvRequisitionReturnLogic
|
||||
{
|
||||
use CrudLogicTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisitionReturn::class;
|
||||
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class CorpFeishuApp extends Model
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'corp_id', 'system_id', 'name', 'tenant_key', 'app_id', 'app_secret', 'base_uri', 'r_url', 'scopes', 'state', 'sort', 'status', 'created_at', 'updated_at'];
|
||||
protected $fillable = ['id', 'corp_id', 'system_id', 'name', 'tenant_key', 'app_id', 'app_secret', 'base_uri', 'm_url', 'r_url', 'scopes', 'state', 'sort', 'status', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class InvItem extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'inv_item';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'company_id', 'item_code', 'item_name',
|
||||
'category_id', 'spec_model', 'unit', 'is_consumable', 'allow_qr_claim', 'qr_code', 'qr_code_num', 'qr_code_url',
|
||||
'stock_qty', 'manage_user_id', 'location', 'remark',
|
||||
'sort', 'status',
|
||||
'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class InvRequisition extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'inv_requisition';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'order_no', 'applicant_company_id', 'dept_id', 'applicant_id', 'applicant_name', 'leader_id', 'manager_id', 'borrow_reason', 'use_start_date', 'use_end_date', 'status', 'claim_type', 'qr_claim_time', 'proxy_claim_user_id', 'proxy_claim_user_name', 'is_returned', 'remark', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
|
||||
// 领取方式常量
|
||||
const CLAIM_TYPE_NORMAL = 1; // 正常申请
|
||||
const CLAIM_TYPE_QR = 2; // 扫码领取
|
||||
const CLAIM_TYPE_PROXY = 3; // 管理员代领
|
||||
|
||||
/**
|
||||
* 领取方式文本
|
||||
*/
|
||||
public static function getClaimTypeText($type)
|
||||
{
|
||||
return [
|
||||
self::CLAIM_TYPE_NORMAL => '正常申请',
|
||||
self::CLAIM_TYPE_QR => '扫码领取',
|
||||
self::CLAIM_TYPE_PROXY => '管理员代领',
|
||||
][$type] ?? '未知';
|
||||
}
|
||||
|
||||
public function approval()
|
||||
{
|
||||
return $this->hasMany('app\common\model\InvRequisitionApprove',
|
||||
'requisition_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 领用明细关联
|
||||
*/
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany('app\common\model\InvRequisitionItem',
|
||||
'requisition_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class InvRequisitionApprove extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'inv_requisition_approve';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'requisition_id', 'node_type', 'approver_id', 'approver_name', 'action', 'comment', 'action_at', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
|
||||
public function requisition()
|
||||
{
|
||||
return $this->belongsTo('app\common\model\InvRequisition',
|
||||
'requisition_id','id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class InvRequisitionApproveAttachment extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'inv_requisition_approve_attachment';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'requisition_id', 'approve_id', 'file_name', 'file_path', 'file_size', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class InvRequisitionAttachment extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'inv_requisition_attachment';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'requisition_id', 'file_name', 'file_path', 'file_size', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class InvRequisitionItem extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'inv_requisition_item';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'requisition_id', 'item_id', 'item_name_snap', 'spec_model_snap', 'unit_snap', 'is_consumable_snap', 'request_qty', 'approved_qty', 'returned_qty', 'is_finished', 'remark', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
|
||||
/**
|
||||
* 关联领用申请主表
|
||||
*/
|
||||
public function requisition()
|
||||
{
|
||||
return $this->belongsTo('app\common\model\InvRequisition', 'requisition_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class InvRequisitionItemReturn extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'inv_requisition_item_return';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'return_id', 'requisition_id', 'requisition_item_id', 'item_id', 'apply_user_id', 'apply_user_name', 'return_qty', 'status', 'handler_id', 'handler_name', 'return_status', 'apply_remark', 'handle_remark', 'return_at', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class InvRequisitionItemReturnAttachment extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'inv_requisition_item_return_attachment';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'return_id', 'file_name', 'file_path', 'file_size', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class InvRequisitionReturn extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'inv_requisition_return';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = ['id', 'order_no', 'requisition_id', 'applicant_id', 'applicant_name', 'apply_remark', 'handler_id', 'handler_name', 'status', 'handle_remark', 'return_at', 'created_at', 'updated_at'];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
class InvRequisitionReturnApproveAttachment extends Model
|
||||
{
|
||||
/**
|
||||
* The connection name for the model.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connection = 'mysql';
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'inv_requisition_return_approve_attachment';
|
||||
|
||||
/**
|
||||
* The primary key associated with the table.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
/**
|
||||
* 允许自动填充模型字段
|
||||
*/
|
||||
protected $fillable = [];
|
||||
|
||||
/**
|
||||
* 不允许自动填充模型字段
|
||||
*/
|
||||
protected $guarded = [];
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use support\Model;
|
||||
|
||||
/**
|
||||
* 物品库存变化记录模型
|
||||
*/
|
||||
class InvStockLog extends Model
|
||||
{
|
||||
protected $table = 'inv_stock_log';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'item_id',
|
||||
'item_name_snap',
|
||||
'change_type',
|
||||
'change_qty',
|
||||
'before_qty',
|
||||
'after_qty',
|
||||
'biz_type',
|
||||
'biz_no',
|
||||
'operator_id',
|
||||
'operator_name',
|
||||
'remark',
|
||||
'updated_at',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* Indicates if the model should be timestamped.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
|
||||
protected $dateFormat = 'U';
|
||||
|
||||
public const CHANGE_TYPE_IN = 1; // 入库
|
||||
public const CHANGE_TYPE_OUT_CLAIM = 2; // 出库(领用)
|
||||
public const CHANGE_TYPE_OUT_LOSS = 3; // 出库(损耗)
|
||||
public const CHANGE_TYPE_IN_RETURN = 4; // 入库(归还)
|
||||
|
||||
public const BIZ_TYPE_PURCHASE = 1; // 采购入库
|
||||
public const BIZ_TYPE_QR_CLAIM = 2; // 扫码领取
|
||||
public const BIZ_TYPE_PROXY_CLAIM = 3; // 管理员代领
|
||||
public const BIZ_TYPE_NORMAL_CLAIM = 4; // 正常领用(审批后)
|
||||
public const BIZ_TYPE_RETURN = 5; // 归还入库
|
||||
public const BIZ_TYPE_LOSS = 6; // 损耗出库
|
||||
|
||||
/**
|
||||
* 变动类型文本
|
||||
*/
|
||||
public static function getChangeTypeText($type)
|
||||
{
|
||||
return [
|
||||
self::CHANGE_TYPE_IN => '入库',
|
||||
self::CHANGE_TYPE_OUT_CLAIM => '出库(领用)',
|
||||
self::CHANGE_TYPE_OUT_LOSS => '出库(损耗)',
|
||||
self::CHANGE_TYPE_IN_RETURN => '入库(归还)',
|
||||
][$type] ?? '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务类型文本
|
||||
*/
|
||||
public static function getBizTypeText($type)
|
||||
{
|
||||
return [
|
||||
self::BIZ_TYPE_PURCHASE => '采购入库',
|
||||
self::BIZ_TYPE_QR_CLAIM => '扫码领取',
|
||||
self::BIZ_TYPE_PROXY_CLAIM => '管理员代领',
|
||||
self::BIZ_TYPE_NORMAL_CLAIM => '正常领用',
|
||||
self::BIZ_TYPE_RETURN => '归还入库',
|
||||
self::BIZ_TYPE_LOSS => '损耗出库',
|
||||
][$type] ?? '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联物品
|
||||
*/
|
||||
public function item()
|
||||
{
|
||||
return $this->belongsTo(InvItem::class, 'item_id', 'id');
|
||||
}
|
||||
}
|
||||
@@ -45,14 +45,7 @@ class StockOrderItems extends Model
|
||||
'id', 'order_id', 'product_code', 'sku_id', 'sku_snapshot',
|
||||
'model_snapshot', 'quantity', 'color_interior_code', 'color_interior_snapshot',
|
||||
'color_exterior_code', 'color_exterior_snapshot', 'remark', 'vin', 'created_at', 'updated_at',
|
||||
'mileage', 'unique_id', 'attachment'
|
||||
];
|
||||
|
||||
/**
|
||||
* 字段类型转换:attachment 以 JSON 数组存取(写自动 json_encode、读自动 decode)
|
||||
*/
|
||||
protected $casts = [
|
||||
'attachment' => 'array',
|
||||
'mileage', 'unique_id'
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace app\common\service;
|
||||
|
||||
use app\common\model\User;
|
||||
use app\common\model\UserGroupMembers;
|
||||
use support\Db;
|
||||
use app\common\validate\ApprovalRecordsValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\ApprovalRecords;
|
||||
@@ -119,31 +118,6 @@ class ApprovalRecordsService
|
||||
$config['where']['status'] = ['!=', 'pending'];
|
||||
}
|
||||
|
||||
// 业务筛选:pi_no 精确到PI号;keyword 匹配 单号/PI号(历史页原有搜索框此前后端未实现)
|
||||
// 两个条件同时传时取交集
|
||||
$bizIdFilter = null;
|
||||
if (!empty($p['pi_no'])) {
|
||||
$ids = Db::table('stock_orders')
|
||||
->where('pi_no', 'like', '%' . trim((string)$p['pi_no']) . '%')
|
||||
->pluck('id')->toArray();
|
||||
$bizIdFilter = $ids ?: [0];
|
||||
}
|
||||
if (!empty($p['keyword'])) {
|
||||
$kw = '%' . trim((string)$p['keyword']) . '%';
|
||||
$ids = Db::table('stock_orders')
|
||||
->where(function ($q) use ($kw) {
|
||||
$q->where('order_no', 'like', $kw)
|
||||
->orWhere('pi_no', 'like', $kw)
|
||||
->orWhere('customer_snapshot', 'like', $kw);
|
||||
})
|
||||
->pluck('id')->toArray();
|
||||
$ids = $ids ?: [0];
|
||||
$bizIdFilter = $bizIdFilter === null ? $ids : array_values(array_intersect($bizIdFilter, $ids));
|
||||
}
|
||||
if ($bizIdFilter !== null) {
|
||||
$config['where']['instance.biz_id'] = ['in', $bizIdFilter];
|
||||
}
|
||||
|
||||
$config['order']['id'] = 'desc';
|
||||
|
||||
$config['with'] = [
|
||||
|
||||
@@ -13,12 +13,12 @@ class AuthService
|
||||
/**
|
||||
* 飞书授权
|
||||
*/
|
||||
public function feishuAuthorize(string $corpAbbr, string $systemCcode)
|
||||
public function feishuAuthorize(string $corpAbbr, string $systemCcode, $mobile)
|
||||
{
|
||||
$logic = new AuthLogic();
|
||||
$certificate = $logic->feishuAppInfo($corpAbbr, $systemCcode);
|
||||
$feishu = new Feishu($certificate['app_id'], $certificate['app_secret'], $certificate['abbr']);
|
||||
return ['auth_url' => $feishu->authorize($certificate)];
|
||||
return ['auth_url' => $feishu->authorize($certificate, $mobile)];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,4 +86,20 @@ class AuthService
|
||||
{
|
||||
(new AuthLogic())->blockToken($token, $systemCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取jsapi配置
|
||||
* @param string $corpAbbr
|
||||
* @param string $systemCcode
|
||||
* @param string $url 当前页面完整 URL(不包含 # 后面的内容)
|
||||
* @return array
|
||||
*/
|
||||
public function getJsApiConfig(string $corpAbbr, string $systemCcode, string $url)
|
||||
{
|
||||
$logic = new AuthLogic();
|
||||
$certificate = $logic->feishuAppInfo($corpAbbr, $systemCcode);
|
||||
$feishu = new Feishu($certificate['app_id'], $certificate['app_secret'], $certificate['abbr']);
|
||||
$jsApi = $feishu->getJsApiConfig($url);
|
||||
return $jsApi;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\logic\InvItemLogic;
|
||||
use app\common\model\User;
|
||||
use app\common\validate\InvItemValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\InvItem;
|
||||
use support\Redis;
|
||||
use support\Request;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
class InvItemService
|
||||
{
|
||||
|
||||
use CrudServiceTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvItem::class;
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected $validator = InvItemValidate::class;
|
||||
|
||||
protected $logic;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->logic = (new InvItemLogic());
|
||||
}
|
||||
/**
|
||||
* 获取验证器
|
||||
*/
|
||||
public function getValidator()
|
||||
{
|
||||
return new $this->validator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 CrudUtil 查询配置
|
||||
*/
|
||||
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
|
||||
{
|
||||
$p = $request->all();
|
||||
|
||||
$config = [];
|
||||
$config['model'] = $this->modelClass;
|
||||
switch ($scene) {
|
||||
case 'default':
|
||||
$config['where']['status'] = 1;
|
||||
$config['order']['sort'] = 'desc';
|
||||
$config['order']['id'] = 'desc';
|
||||
break;
|
||||
case 'index':
|
||||
$config['where']['status'] = 1;
|
||||
|
||||
// 关键字
|
||||
if (isset($p['keyword']) && $p['keyword']) {
|
||||
$k = '%' . $p['keyword'] . '%';
|
||||
foreach (['item_name'] as $col) {
|
||||
$config['orWhere'][] = [$col, 'like', $k];
|
||||
}
|
||||
}
|
||||
|
||||
$config['order']['sort'] = 'desc';
|
||||
$config['order']['id'] = 'desc';
|
||||
break;
|
||||
case 'show':
|
||||
$config['where']['id'] = $p['id'];
|
||||
$config['where']['status'] = 1;
|
||||
break;
|
||||
case 'store':
|
||||
break;
|
||||
case 'update':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
case 'destroy':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 8-12 位的物品编码(默认10位)
|
||||
*
|
||||
* @param int $length 编码长度,默认为10(范围 8-12)
|
||||
* @return string 返回大写的字母数字组合编码
|
||||
*/
|
||||
private function generateItemCode($length = 10)
|
||||
{
|
||||
// 排除易混淆字符:0, O, o, 1, I, l
|
||||
$chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
|
||||
$code = '';
|
||||
$maxIndex = strlen($chars) - 1;
|
||||
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$code .= $chars[mt_rand(0, $maxIndex)];
|
||||
}
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
|
||||
public function beforeServiceStore($data, Request $request): array
|
||||
{
|
||||
$data['item_code'] = $this->generateItemCode();
|
||||
$data['category_id'] = 0;
|
||||
$data['spec_model'] = '';
|
||||
$data['unit'] = '件';
|
||||
$data['stock_qty'] = $data['stock_qty']??1;
|
||||
$data['location'] = '';
|
||||
|
||||
Redis::set('inv_store_u_stock', $request->user->uid, 'EX', 30);
|
||||
|
||||
return $data;
|
||||
}
|
||||
public function afterServiceStore($res)
|
||||
{
|
||||
$uid = redis::get('inv_store_u_stock');
|
||||
$name = $uid ? User::where('id', $uid)->value('name') : '';
|
||||
Redis::del('inv_store_u_stock');
|
||||
// 记录库存变化日志
|
||||
$this->recordStockLog([
|
||||
'item_id' => $res['id'],
|
||||
'item_name_snap' => $res['item_name'],
|
||||
'change_qty' => $res['stock_qty'],
|
||||
'before_qty' => 0,
|
||||
'after_qty' => $res['stock_qty'],
|
||||
'biz_type' => 1,
|
||||
'biz_no' => '',
|
||||
'operator_id' => $uid,
|
||||
'operator_name' => $name,
|
||||
'remark' => '初始数量',
|
||||
], $res['stock_qty']);
|
||||
}
|
||||
|
||||
public function beforeServiceUpdate($data, Request $request)
|
||||
{
|
||||
$uid = $request->user->uid;
|
||||
$name = $uid ? User::where('id', $uid)->value('name') : '';
|
||||
$item = InvItem::where('id', $data['id'])->first();
|
||||
$change_qty = $data['stock_qty'] - $item['stock_qty'];
|
||||
// 记录库存变化日志
|
||||
$this->recordStockLog([
|
||||
'item_id' => $data['id'],
|
||||
'item_name_snap' => $data['item_name'],
|
||||
'change_qty' => $change_qty,
|
||||
'before_qty' => $item['stock_qty'],
|
||||
'after_qty' => $data['stock_qty'],
|
||||
'biz_type' => 1,
|
||||
'biz_no' => '',
|
||||
'operator_id' => $uid,
|
||||
'operator_name' => $name,
|
||||
'remark' => '管理员修改数量',
|
||||
], $change_qty);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 变动物品库存数量
|
||||
*
|
||||
* @param int $itemId 物品ID
|
||||
* @param int $changeQty 变动数量:>0 入库,<0 出库
|
||||
* @param bool $allowNegative 是否允许库存变成负数,默认不允许
|
||||
* @param array $options 额外选项(用于记录日志)
|
||||
* - change_type: 变动类型
|
||||
* - biz_type: 业务类型
|
||||
* - biz_no: 单据号
|
||||
* - operator_id: 操作人ID
|
||||
* - operator_name: 操作人姓名
|
||||
* - remark: 备注
|
||||
* @return InvItem 最新的物品记录
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function changeStock(int $itemId, int $changeQty, bool $allowNegative = false, array $options = []): InvItem
|
||||
{
|
||||
if ($changeQty === 0) {
|
||||
throw new BusinessException('库存变动数量不能为 0');
|
||||
}
|
||||
|
||||
// 获取变动前的库存(用于日志)
|
||||
$itemBefore = InvItem::query()->find($itemId);
|
||||
if (!$itemBefore) {
|
||||
throw new BusinessException('物品不存在');
|
||||
}
|
||||
$beforeQty = $itemBefore->stock_qty;
|
||||
|
||||
$now = time();
|
||||
|
||||
// 构建查询
|
||||
$query = InvItem::query()->where('id', $itemId);
|
||||
|
||||
// 如果不允许负数,并且是出库场景(变动为负)
|
||||
if (! $allowNegative && $changeQty < 0) {
|
||||
// 要保证:当前 stock_qty >= 出库数量
|
||||
$query->where('stock_qty', '>=', -$changeQty);
|
||||
}
|
||||
|
||||
// 使用 increment 实现:UPDATE inv_item SET stock_qty = stock_qty + ?, updated_at = ? WHERE ...
|
||||
$affected = $query->increment('stock_qty', $changeQty, [
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if ($affected === 0) {
|
||||
// 更新不到行:
|
||||
// 1) 物品不存在
|
||||
// 2) 或者库存不足(不允许负数时)
|
||||
throw new BusinessException('库存不足,无法完成库存变动');
|
||||
}
|
||||
|
||||
// 再查一次最新数据返回(在外层事务中也会保持一致)
|
||||
/** @var InvItem $item */
|
||||
$item = InvItem::query()->find($itemId);
|
||||
|
||||
// 记录库存变化日志
|
||||
$this->recordStockLog([
|
||||
'item_id' => $itemId,
|
||||
'item_name_snap' => $item->item_name,
|
||||
'change_qty' => $changeQty,
|
||||
'before_qty' => $beforeQty,
|
||||
'after_qty' => $item->stock_qty,
|
||||
'change_type' => $options['change_type'] ?? null,
|
||||
'biz_type' => $options['biz_type'] ?? null,
|
||||
'biz_no' => $options['biz_no'] ?? null,
|
||||
'operator_id' => $options['operator_id'] ?? null,
|
||||
'operator_name' => $options['operator_name'] ?? null,
|
||||
'remark' => $options['remark'] ?? null,
|
||||
], $changeQty);
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 出库(减库存,默认不允许负数)
|
||||
*
|
||||
* @param int $itemId 物品ID
|
||||
* @param int $qty 出库数量
|
||||
* @param bool $allowNegative 是否允许负数
|
||||
* @param array $options 日志选项(同 changeStock)
|
||||
* @return InvItem
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function stockOut(int $itemId, int $qty, bool $allowNegative = false, array $options = []): InvItem
|
||||
{
|
||||
if ($qty <= 0) {
|
||||
throw new BusinessException('出库数量必须大于 0');
|
||||
}
|
||||
// 确定变动类型:2-出库(领用)或 3-出库(损耗)
|
||||
$changeType = $options['change_type'] ?? \app\common\model\InvStockLog::CHANGE_TYPE_OUT_CLAIM;
|
||||
|
||||
return $this->changeStock($itemId, -$qty, $allowNegative, array_merge($options, [
|
||||
'change_type' => $changeType,
|
||||
'remark' => $options['remark'] ?? '领用出库',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 入库(加库存)
|
||||
*
|
||||
* @param int $itemId 物品ID
|
||||
* @param int $qty 入库数量
|
||||
* @param array $options 日志选项(同 changeStock)
|
||||
* @return InvItem
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function stockIn(int $itemId, int $qty, array $options = []): InvItem
|
||||
{
|
||||
if ($qty <= 0) {
|
||||
throw new BusinessException('入库数量必须大于 0');
|
||||
}
|
||||
// 确定变动类型:1-入库 或 4-入库(归还)
|
||||
$changeType = $options['change_type'] ?? \app\common\model\InvStockLog::CHANGE_TYPE_IN;
|
||||
|
||||
return $this->changeStock($itemId, $qty, true, array_merge($options, [
|
||||
'change_type' => $changeType,
|
||||
'remark' => $options['remark'] ?? '入库',
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录库存变化日志
|
||||
*
|
||||
* @param array $logData
|
||||
* @param int $changeQty 变动数量
|
||||
* @return void
|
||||
*/
|
||||
private function recordStockLog(array $logData, int $changeQty): void
|
||||
{
|
||||
try {
|
||||
// 根据变动数量确定变动类型(如果未指定)
|
||||
if (!isset($logData['change_type'])) {
|
||||
if ($changeQty > 0) {
|
||||
$logData['change_type'] = \app\common\model\InvStockLog::CHANGE_TYPE_IN;
|
||||
} else {
|
||||
$logData['change_type'] = \app\common\model\InvStockLog::CHANGE_TYPE_OUT_CLAIM;
|
||||
}
|
||||
}
|
||||
|
||||
(new InvStockLogService())->record($logData);
|
||||
} catch (\Throwable $e) {
|
||||
// 日志记录失败不影响主业务,仅记录错误
|
||||
error_log('库存日志记录失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取车型list
|
||||
*/
|
||||
public function getList()
|
||||
{
|
||||
return $this->logic->getList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\model\InvItem;
|
||||
use app\common\model\User;
|
||||
use app\common\validate\InvRequisitionApproveValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\InvRequisitionApprove;
|
||||
use support\Db;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
class InvRequisitionApproveService
|
||||
{
|
||||
|
||||
use CrudServiceTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisitionApprove::class;
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected $validator = InvRequisitionApproveValidate::class;
|
||||
|
||||
/**
|
||||
* 获取验证器
|
||||
*/
|
||||
public function getValidator()
|
||||
{
|
||||
return new $this->validator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $flowType
|
||||
* @param string $bizType
|
||||
* @param string $bizId
|
||||
* @param array $formData ['manage_user_id'=>23]//物品管理员
|
||||
* @param int $initiatorId //申请人id
|
||||
* @return array
|
||||
*/
|
||||
public function start(string $flowType, string $bizType, string $bizId, array $formData, int $initiatorId): array
|
||||
{
|
||||
return Db::transaction(function () use ($flowType, $bizType, $bizId, $formData, $initiatorId) {
|
||||
$flow = Db::table('approval_flows')->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');
|
||||
$now = time();
|
||||
|
||||
|
||||
$service = new \app\common\utils\approval\service\ApprovalFlowService();
|
||||
|
||||
// 解析首步审批人(支持 role_id 或 roles.name)
|
||||
$instance = (object)[
|
||||
'initiator_id' => $initiatorId,
|
||||
'form_data'=>json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
];
|
||||
$approverIds = $service->StetApprovers($firstStep, $instance, true);
|
||||
|
||||
if(empty($approverIds)){
|
||||
throw new BusinessException('未解析到任何审批人');
|
||||
}
|
||||
$uid = array_shift($approverIds);
|
||||
$approver = User::where('id', $uid)->first();
|
||||
|
||||
$approveId = Db::table('inv_requisition_approve')->insertGetId([
|
||||
'requisition_id' => $bizId, //申请单ID
|
||||
'node_type' => $bizType,
|
||||
'approver_id' => $approver->id,
|
||||
'approver_name' => $approver->name,
|
||||
'action' => 0,
|
||||
'comment' => '',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// foreach ($approverIds as $uid) {
|
||||
// $this->insertApprovalRecord($instance, $firstIndex, $uid);
|
||||
// }
|
||||
|
||||
return ['instance_id' => $approveId];
|
||||
});
|
||||
}
|
||||
|
||||
/** 执行审批 */
|
||||
public function approve(int $requisitionId, int $approverId, int $uid, string $action, string $comment = '', array $attachment = [])
|
||||
{
|
||||
if (!in_array($action, ['1','2'], true)) { //审批动作:1通过 2驳回
|
||||
throw new BusinessException("无效的审批操作");
|
||||
}
|
||||
|
||||
return Db::transaction(function () use ($requisitionId, $approverId, $uid, $action, $comment, $attachment) {
|
||||
// $instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first();
|
||||
$requisition = Db::table('inv_requisition')->where('id', $requisitionId)->lockForUpdate()->first();
|
||||
if (!$requisition) throw new BusinessException("审批实例未找到");
|
||||
if ($requisition->status !== 10 && $requisition->status !== 11) {
|
||||
return ['status' => 'noop', 'msg' => '实例未处理'];
|
||||
}
|
||||
|
||||
//审批流程
|
||||
$flow = Db::table('approval_flows')->where('type', 'inv_requisition')->first();
|
||||
if (!$flow) throw new BusinessException("审批流程定义未找到");
|
||||
|
||||
$steps = json_decode($flow->steps, true);
|
||||
//$idx = (int)$requisition->current_step;
|
||||
//$step = $steps[$idx] ?? null;
|
||||
//if (!$step) throw new BusinessException("当前步骤配置错误");
|
||||
|
||||
// 找到该用户在本步的 pending 记录(加锁)
|
||||
$pendingRecord = Db::table('inv_requisition_approve')
|
||||
->where('requisition_id', $requisitionId)
|
||||
->where('id', $approverId)
|
||||
->where('action', 0)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$pendingRecord) {
|
||||
// 已处理过?
|
||||
$anyRecord = Db::table('inv_requisition_approve')
|
||||
->where('requisition_id', $requisitionId)
|
||||
->where('id', $approverId)
|
||||
->first();
|
||||
if ($anyRecord && $anyRecord->action > 0) {
|
||||
return ['status' => 'noop', 'msg' => '已处理'];
|
||||
}
|
||||
// 步骤已推进
|
||||
// if ((int)$requisition->current_step !== $idx) {
|
||||
// return ['status' => 'noop', 'msg' => '非当前审批'];
|
||||
// }
|
||||
return ['status' => 'noop', 'msg' => '无待处理记录'];
|
||||
} else if ($pendingRecord->approver_id != $uid) { //检查当前用户是否为审核人
|
||||
return ['status' => 'noop', 'msg' => '无审核权限'];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
|
||||
// 1) 更新当前人的审批记录
|
||||
Db::table('inv_requisition_approve')->where('id', $pendingRecord->id)->update([
|
||||
'action' => $action,
|
||||
'comment' => $comment,
|
||||
'action_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
foreach ($attachment as $attac) {
|
||||
Db::table('inv_requisition_approve_attachment')->insertGetId([
|
||||
'requisition_id' => $requisitionId,
|
||||
'approve_id' => $approverId,
|
||||
'file_name' => $attac['original_name'],
|
||||
'file_path' => $attac['file_path'],
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
// 2) 已通过步骤与总步数相等则结束审核流程
|
||||
$pass = Db::table('inv_requisition_approve')
|
||||
->where('requisition_id', $requisitionId)
|
||||
->where('action', 1)
|
||||
->count();
|
||||
if($action == 1){
|
||||
if(count($steps) == $pass){
|
||||
//判断是否都是消耗品,是否需要归还
|
||||
$requisitionItem = Db::table('inv_requisition_item')
|
||||
->where('requisition_id', $requisition->id)
|
||||
->where('is_consumable_snap', 0)
|
||||
->get();
|
||||
if($requisitionItem){
|
||||
Db::table('inv_requisition')->where('id', $requisition->id)->update(['status'=>12]);
|
||||
}else{
|
||||
Db::table('inv_requisition')->where('id', $requisition->id)->update(['status'=>19]);
|
||||
}
|
||||
|
||||
//更改库存
|
||||
$requisitionItem = Db::table('inv_requisition_item')
|
||||
->where('requisition_id', $requisition->id)
|
||||
->get();
|
||||
foreach ($requisitionItem as $item) {
|
||||
//$invItem = InvItem::where('id', $item->item_id)->first();
|
||||
(new InvItemService())->stockOut($item->item_id,$item->approved_qty);
|
||||
}
|
||||
|
||||
// $requisition->status = 12;
|
||||
// $requisition->save();
|
||||
return ['status' => 'complete', 'msg' => '已通过'];
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 拒绝 => 结束
|
||||
if($action == 2){
|
||||
Db::table('inv_requisition_approve')->where('id', $pendingRecord->id)->update([
|
||||
'action' => $action,
|
||||
'comment' => $comment,
|
||||
'approved_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
//if(count($steps) == $pass){
|
||||
Db::table('inv_requisition')->where('id', $requisition->id)->update(['status'=>13]);
|
||||
return ['status' => 'complete', 'msg' => '拒绝'];
|
||||
//}
|
||||
}
|
||||
|
||||
|
||||
// 4) 通过 => 进入下一步
|
||||
Db::table('inv_requisition')->where('id', $requisitionId)->update(['status'=>11]);
|
||||
$requisitionItem = Db::table('inv_requisition_item')->where('requisition_id', $requisitionId)->first();
|
||||
$item_id = Db::table('inv_item')->where('id', $requisitionItem->item_id)->first();
|
||||
$formData = ['manage_user_id'=>$item_id->manage_user_id];
|
||||
|
||||
$nextIndex = $pass;
|
||||
$nextStep = $steps[$nextIndex];
|
||||
$service = new \app\common\utils\approval\service\ApprovalFlowService();
|
||||
|
||||
$instance = (object)[
|
||||
'initiator_id' => $requisition->applicant_id, //申请人id
|
||||
'form_data'=>json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
];
|
||||
$approverIds = $service->StetApprovers($nextStep, $instance, true);
|
||||
// if($approverIds == "manage_user_id"){ //物品管理人审核步骤查询管理人
|
||||
// $requisitionItem = Db::table('inv_requisition_item')->where('id', $requisitionId)->first();
|
||||
// $invItem = Db::table('inv_requisition_item')->where('id', $requisitionItem->item_id)->first();
|
||||
//
|
||||
// if(empty($invItem)){
|
||||
// return ['status' => 'noop', 'msg' => '未查询到物品管理人'];
|
||||
// }
|
||||
// $approverIds = [$invItem->manage_user_id];
|
||||
// }
|
||||
|
||||
$approver = User::where('id', array_pop($approverIds))->first();
|
||||
Db::table('inv_requisition_approve')->insertGetId([
|
||||
'requisition_id' => $requisitionId, //申请单ID
|
||||
'node_type' => $nextStep['approvers'][0]['value'],
|
||||
'approver_id' => $approver->id,
|
||||
'approver_name' => $approver->name,
|
||||
'action' => 0,
|
||||
'comment' => '',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);;
|
||||
return ['status' => 'complete', 'msg' => '已通过'];
|
||||
});
|
||||
}
|
||||
|
||||
// public function insertApprovalRecord()
|
||||
// {
|
||||
// }
|
||||
|
||||
|
||||
/** 获取下一个有效步骤索引 */
|
||||
private function getNextStepIndex(array $steps, array $formData, int $currentStepIndex): ?int
|
||||
{
|
||||
for ($i = $currentStepIndex + 1; $i < count($steps); $i++) {
|
||||
$s = $steps[$i];
|
||||
if (empty($s['condition'])) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\validate\InvRequisitionAttachmentValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\InvRequisitionAttachment;
|
||||
|
||||
class InvRequisitionAttachmentService
|
||||
{
|
||||
|
||||
use CrudServiceTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisitionAttachment::class;
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected $validator = InvRequisitionAttachmentValidate::class;
|
||||
|
||||
/**
|
||||
* 获取验证器
|
||||
*/
|
||||
public function getValidator()
|
||||
{
|
||||
return new $this->validator;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\validate\InvRequisitionItemReturnAttachmentValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\InvRequisitionItemReturnAttachment;
|
||||
|
||||
class InvRequisitionItemReturnAttachmentService
|
||||
{
|
||||
|
||||
use CrudServiceTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisitionItemReturnAttachment::class;
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected $validator = InvRequisitionItemReturnAttachmentValidate::class;
|
||||
|
||||
/**
|
||||
* 获取验证器
|
||||
*/
|
||||
public function getValidator()
|
||||
{
|
||||
return new $this->validator;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\model\InvItem;
|
||||
use app\common\model\InvRequisition;
|
||||
use app\common\model\InvRequisitionItem;
|
||||
use app\common\model\InvRequisitionReturn;
|
||||
use app\common\model\InvStockLog;
|
||||
use app\common\model\User;
|
||||
use app\common\service\UserService;
|
||||
use app\common\validate\InvRequisitionItemReturnValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\InvRequisitionItemReturn;
|
||||
use support\Request;
|
||||
use support\Db;
|
||||
use support\exception\BusinessException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use app\common\model\InvRequisitionItemReturnAttachment;
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
class InvRequisitionItemReturnService
|
||||
{
|
||||
|
||||
use CrudServiceTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisitionItemReturn::class;
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected $validator = InvRequisitionItemReturnValidate::class;
|
||||
|
||||
/**
|
||||
* 获取验证器
|
||||
*/
|
||||
public function getValidator()
|
||||
{
|
||||
return new $this->validator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将二维数组按指定 key 重组为关联数组,该 key 的值作为新数组的键,
|
||||
* 其余元素组成子数组作为值。
|
||||
*
|
||||
* @param array $array 原始二维数组
|
||||
* @param string $keyField 用作新键的字段名(如 'id')
|
||||
* @return array 重组后的数组
|
||||
*/
|
||||
private function groupArrayByKey($array, $keyField = 'id')
|
||||
{
|
||||
$result = [];
|
||||
foreach ($array as $item) {
|
||||
if (!isset($item[$keyField])) {
|
||||
continue; // 跳过没有该 key 的项
|
||||
}
|
||||
$key = $item[$keyField];
|
||||
// 移除用作键的字段
|
||||
unset($item[$keyField]);
|
||||
$result[$key] = $item;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 创建物品归还申请前置处理
|
||||
*
|
||||
* @param array $data 归还申请数据
|
||||
* @param Request $request 请求对象
|
||||
* @return array 处理后的数据
|
||||
*/
|
||||
public function beforeServiceStore($data, Request $request): array
|
||||
{
|
||||
try {
|
||||
// // 模拟数据
|
||||
// $data = [
|
||||
// [
|
||||
// 'requisition_item_id' => 9,
|
||||
// 'return_qty' => 1,
|
||||
// 'apply_remark' => '申请备注',
|
||||
// 'attachment' => [
|
||||
// [
|
||||
// 'original_name' => '附件1.jpg',
|
||||
// 'file_path' => '/uploads/2023/07/01/attachment1.jpg'
|
||||
// ],
|
||||
// [
|
||||
// 'original_name' => '附件2.jpg',
|
||||
// 'file_path' => '/uploads/2023/07/01/attachment2.jpg'
|
||||
// ]
|
||||
// ]
|
||||
// ]
|
||||
// ];
|
||||
|
||||
// 获取所有申请项ID
|
||||
$reqItemIds = array_column($data['items'], 'requisition_item_id');
|
||||
|
||||
// 参数校验
|
||||
if (empty($reqItemIds)) {
|
||||
throw new BusinessException('未提供有效的申请项ID');
|
||||
}
|
||||
|
||||
// 将申请项按 ID 重组为关联数组
|
||||
$newReqItems = $this->groupArrayByKey($data['items'], 'requisition_item_id');
|
||||
|
||||
$data['return_at'] = is_int($data['return_at']) ? $data['return_at']: strtotime($data['return_at']);
|
||||
|
||||
// 获取申请项明细
|
||||
$reqItemsArr = InvRequisitionItem::whereIn('id', $reqItemIds)->get()->toArray();
|
||||
|
||||
// 检查是否获取到有效数据
|
||||
if (empty($reqItemsArr)) {
|
||||
throw new BusinessException('未找到对应的申请项');
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
$userService = new UserService();
|
||||
$user = $userService->getUserInfo($request->user->uid, '');
|
||||
$data['applicant_id'] = $user['user']['id'];
|
||||
$data['applicant_name'] = $user['user']['name'];
|
||||
|
||||
//$reqItemId = $data['requisition_id'];
|
||||
$arr = [];
|
||||
foreach ($reqItemsArr as $reqItem) {
|
||||
$reqItemId = $reqItem['id'];
|
||||
// 检查是否有对应的新申请项数据
|
||||
if (!isset($newReqItems[$reqItemId])) {
|
||||
continue;
|
||||
}
|
||||
$item = InvItem::where('id', $reqItem['item_id'])->first();
|
||||
$manage = User::where('id',$item['manage_user_id'])->first();
|
||||
|
||||
$arr[] = [
|
||||
'requisition_id' => $data['requisition_id'],
|
||||
'requisition_item_id' => $reqItem['id'],
|
||||
'item_id' => $reqItem['item_id'],
|
||||
'apply_user_id' => $user['user']['id'],
|
||||
'apply_user_name' => $user['user']['name'],
|
||||
'return_qty' => $newReqItems[$reqItemId]['return_qty'] ?: '1',
|
||||
'apply_remark' => $data['apply_remark'] ?? '',
|
||||
'handler_id' => $item['manage_user_id'] ?? 0,
|
||||
'handler_name' => $manage['name'] ?? '',
|
||||
//'attachment' => $newReqItems[$reqItemId]['attachment'],
|
||||
//'return_at' => time(),
|
||||
'return_at' => $data['return_at'],
|
||||
];
|
||||
$data['handler_id'] = $item['manage_user_id'] ?? 0;
|
||||
$data['handler_name'] = $manage['name'] ?? '';
|
||||
}
|
||||
$data['items'] =$arr;
|
||||
|
||||
return $data;
|
||||
} catch (BusinessException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $th) {
|
||||
throw new BusinessException('创建物品归还申请失败:' . $th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理物品申请
|
||||
*
|
||||
* @param Request $request
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
public function serviceStore(Request $request, array $data, string $scene = '')
|
||||
{
|
||||
try {
|
||||
// 关联表增加事务处理
|
||||
return Db::transaction(function () use ($request, $data) {
|
||||
$data = $this->preProcess('Store', $data, $request);
|
||||
//$dl = $this->getDataLimitFromRequest($request);
|
||||
|
||||
$items = $data['items'];
|
||||
$attachment = $data['attachment'];
|
||||
unset($data['items']);
|
||||
unset($data['attachment']);
|
||||
$data['order_no'] = $this->generateOrderNo();
|
||||
$res = InvRequisitionReturn::create($data);
|
||||
$created = [];
|
||||
foreach ($items as $item) {
|
||||
//$created = $this->logicCreate($item, $dl);
|
||||
$item['return_id'] = $res->id;
|
||||
$item['requisition_id'] = $data['requisition_id'];
|
||||
$item['return_at'] = $data['return_at'];
|
||||
$item['apply_remark'] = $data['apply_remark'];
|
||||
$created = $this->logicCreate($item);
|
||||
}
|
||||
$this->processRequisitionItemReturnAttachment($res, $attachment);
|
||||
InvRequisition::where('id',$request->post('requisition_id'))->update(
|
||||
['status' => 14] //审批状态:10默认、11审批中、12已审批(待归还)、13拒绝、14申请归还、15归还审核中、16部分归还、17已全部归还、18已拒绝、19已审批(无需归还)
|
||||
);
|
||||
$this->postProcess('Store', $created);
|
||||
return $res;
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new BusinessException("创建物品申请失败:" . $e->getMessage().$e->getTraceAsString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 更新物品归还申请前置处理
|
||||
*
|
||||
* @param array $data 归还申请数据
|
||||
* @param Request $request 请求对象
|
||||
* @return array 处理后的数据
|
||||
*/
|
||||
public function beforeServiceUpdate($data, Request $request): array
|
||||
{
|
||||
// // 模拟数据
|
||||
// $data = [
|
||||
// 'id' => 1,
|
||||
// 'requisition_item_id' => 9,
|
||||
// 'return_qty' => 1,
|
||||
// 'apply_remark' => '申请备注22',
|
||||
// 'attachment' => [
|
||||
// [
|
||||
// 'original_name' => '附件1.jpg',
|
||||
// 'file_path' => '/uploads/2023/07/01/attachment1.jpg'
|
||||
// ],
|
||||
// [
|
||||
// 'original_name' => '附件2.jpg',
|
||||
// 'file_path' => '/uploads/2023/07/01/attachment2.jpg'
|
||||
// ],
|
||||
// [
|
||||
// 'original_name' => '附件3.jpg',
|
||||
// 'file_path' => '/uploads/2023/07/01/attachment3.jpg'
|
||||
// ]
|
||||
// ]
|
||||
// ];
|
||||
|
||||
try {
|
||||
$approved = InvRequisitionItemReturn::where('return_id',$data['id'])
|
||||
->where('status','>',0)
|
||||
->count();
|
||||
if ($approved > 0) {
|
||||
throw new BusinessException('已审核禁止编辑');
|
||||
}
|
||||
|
||||
// 获取所有申请项ID
|
||||
$reqItemIds = array_column($data['items'], 'requisition_item_id');
|
||||
|
||||
// 参数校验
|
||||
if (empty($reqItemIds)) {
|
||||
throw new BusinessException('未提供有效的申请项ID');
|
||||
}
|
||||
|
||||
// 将申请项按 ID 重组为关联数组
|
||||
$newReqItems = $this->groupArrayByKey($data['items'], 'requisition_item_id');
|
||||
|
||||
$data['return_at'] = is_int($data['return_at']) ? $data['return_at']: strtotime($data['return_at']);
|
||||
|
||||
// 获取申请项明细
|
||||
$reqItemsArr = InvRequisitionItem::whereIn('id', $reqItemIds)->get()->toArray();
|
||||
|
||||
// 检查是否获取到有效数据
|
||||
if (empty($reqItemsArr)) {
|
||||
throw new BusinessException('未找到对应的申请项');
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
$userService = new UserService();
|
||||
$user = $userService->getUserInfo($request->user->uid, '');
|
||||
$data['applicant_id'] = $user['user']['id'];
|
||||
$data['applicant_name'] = $user['user']['name'];
|
||||
|
||||
//$reqItemId = $data['requisition_id'];
|
||||
$arr = [];
|
||||
foreach ($reqItemsArr as $reqItem) {
|
||||
$reqItemId = $reqItem['id'];
|
||||
// 检查是否有对应的新申请项数据
|
||||
if (!isset($newReqItems[$reqItemId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$arr[] = [
|
||||
'requisition_id' => $data['requisition_id'],
|
||||
'requisition_item_id' => $reqItem['id'],
|
||||
'item_id' => $reqItem['item_id'],
|
||||
'apply_user_id' => $user['user']['id'],
|
||||
'apply_user_name' => $user['user']['name'],
|
||||
'return_qty' => $newReqItems[$reqItemId]['return_qty'] ?: '1',
|
||||
'apply_remark' => $data['apply_remark'] ?? '',
|
||||
//'attachment' => $newReqItems[$reqItemId]['attachment'],
|
||||
//'return_at' => time(),
|
||||
'return_at' => $data['return_at'],
|
||||
];
|
||||
}
|
||||
$data['items'] =$arr;
|
||||
|
||||
return $data;
|
||||
} catch (BusinessException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $th) {
|
||||
throw new BusinessException('创建物品归还申请失败:' . $th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新物品归还申请
|
||||
*
|
||||
* @param Request $request
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
public function serviceUpdate(Request $request, array $data, string $scene = '')
|
||||
{
|
||||
try {
|
||||
// 关联表增加事务处理
|
||||
return Db::transaction(function () use ($request, $data) {
|
||||
$data = $this->preProcess('Update', $data, $request);
|
||||
//$config = $this->buildQueryConfig($request, 'update');
|
||||
|
||||
$attachment = $data['attachment'];
|
||||
unset($data['attachment']);
|
||||
$items = $data['items'];
|
||||
unset($data['items']);
|
||||
|
||||
|
||||
$config = [];
|
||||
$config['model'] = InvRequisitionReturn::class;
|
||||
$config['where']['id'] = $request->post('id');
|
||||
$returnService = new InvRequisitionReturnService();
|
||||
$returnService->logicUpdateBy($config, $data);
|
||||
|
||||
InvRequisitionItemReturn::where('return_id',$data['id'])->delete();
|
||||
foreach ($items as $item) {
|
||||
$item['return_id'] = $data['id'];
|
||||
$item['requisition_id'] = $data['requisition_id'];
|
||||
$item['return_at'] = $data['return_at'];
|
||||
$item['apply_remark'] = $data['apply_remark'];
|
||||
$created = $this->logicCreate($item);
|
||||
//$this->logicUpdateBy($config, $data);
|
||||
}
|
||||
// 获取更新后的完整记录
|
||||
$updated = $returnService->logicShow($config);
|
||||
// 处理附件更新
|
||||
$this->processRequisitionItemReturnAttachment($updated, $attachment, true);
|
||||
return $this->postProcess('Update', $updated);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new BusinessException("更新物品归还申请失败:" . $e->getMessage().$e->getLine().$e->getFile().$e->getTraceAsString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理物品申请附件
|
||||
*
|
||||
* @param Model $created
|
||||
* @param array $items
|
||||
* @param bool $isUpdate
|
||||
* @return void
|
||||
*/
|
||||
private function processRequisitionItemReturnAttachment(Model $created, array $attachment, bool $isUpdate = false): void
|
||||
{
|
||||
if ($isUpdate) {
|
||||
$this->clearRequisitionItemReturnAttachment($created->id);
|
||||
}
|
||||
|
||||
foreach ($attachment as $file) {
|
||||
$attachmentData = [
|
||||
'return_id' => $created->id,
|
||||
'file_name' => $file['original_name'],
|
||||
'file_path' => $file['file_path']
|
||||
];
|
||||
InvRequisitionItemReturnAttachment::create($attachmentData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空物品申请附件
|
||||
*
|
||||
* @param int $returnId
|
||||
* @return void
|
||||
*/
|
||||
private function clearRequisitionItemReturnAttachment(int $returnId): void
|
||||
{
|
||||
InvRequisitionItemReturnAttachment::where('return_id', $returnId)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 物品领用归还确认
|
||||
*
|
||||
* @param Request $request
|
||||
* @param array $data
|
||||
* @param string $scene
|
||||
* @return array|bool
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function serviceReturnItemConfirm(Request $request, array $data, string $scene = 'returnConfirm')
|
||||
{
|
||||
try {
|
||||
$attachment = $request->post('attachment') ?? [];
|
||||
unset($data['attachment']);
|
||||
// 归还确认是业务顶层方法,这里统一使用事务
|
||||
return Db::transaction(function () use ($request, $data, $scene, $attachment) {
|
||||
|
||||
// 1. 当前操作人信息
|
||||
$userInfo = (new UserService())->getUserInfo($request->user->uid, '');
|
||||
if (empty($userInfo['user']['id']) || empty($userInfo['user']['name'])) {
|
||||
throw new BusinessException('无法获取当前用户信息');
|
||||
}
|
||||
|
||||
$handlerId = (int)$userInfo['user']['id'];
|
||||
$handlerName = (string)$userInfo['user']['name'];
|
||||
|
||||
$data['handler_id'] = $handlerId;
|
||||
$data['handler_name'] = $handlerName;
|
||||
|
||||
// 确认状态:1=通过,2=拒绝 (按你现在用法)
|
||||
$status = isset($data['status']) ? (int)$data['status'] : null;
|
||||
if (!in_array($status, [1, 2], true)) {
|
||||
throw new BusinessException('非法的归还处理状态');
|
||||
}
|
||||
|
||||
// 2. 构建查询配置,定位到这条归还记录
|
||||
$config = $this->buildQueryConfig($request, $scene);
|
||||
|
||||
// 更新时不允许前端覆盖主键
|
||||
unset($data['requisition_item_return_id']);
|
||||
|
||||
// 3. 获取现有记录
|
||||
/** @var \app\model\InvRequisitionItemReturn|null $existingRecord */
|
||||
$existingRecord = $this->logicShow($config);
|
||||
|
||||
if (!$existingRecord) {
|
||||
throw new BusinessException('未找到对应的归还申请记录');
|
||||
}
|
||||
|
||||
// 已处理校验:只要归还记录的 status 已经是「通过或拒绝」,就不能再处理
|
||||
$existingStatus = (int)($existingRecord->status ?? 0);
|
||||
if (in_array($existingStatus, [1, 2], true) || !empty($existingRecord->handler_id)) {
|
||||
throw new BusinessException('该归还申请已经被处理过了');
|
||||
}
|
||||
|
||||
// 4. 更新归还记录(状态 + 审核人 + 备注等)
|
||||
$updateResult = $this->logicUpdateBy($config, $data);
|
||||
$resultItem = $this->logicShow($config);
|
||||
InvRequisitionReturn::where('id',$resultItem['return_id'])->update(['status' => $data['status']]);
|
||||
foreach ($attachment as $attac) {
|
||||
Db::table('inv_requisition_return_approve_attachment')->insertGetId([
|
||||
'requisition_id' => $resultItem['requisition_id'],
|
||||
'return_id' => $resultItem['return_id'],
|
||||
'file_name' => $attac['original_name'],
|
||||
'file_path' => $attac['file_path'],
|
||||
'created_at' => time(),
|
||||
]);
|
||||
}
|
||||
if ($updateResult === 0) {
|
||||
throw new BusinessException('更新归还申请失败,可能记录不存在或已被处理');
|
||||
}
|
||||
|
||||
//InvRequisitionReturn::where('return_id', $data['id'])->update(['status' => $status]);
|
||||
|
||||
// 5. 若是拒绝,直接返回(不改库存、不改已归还数量)
|
||||
if ($status === 2) {
|
||||
// 拒绝:这里你返回 true 或者返回更新后的记录都可以
|
||||
return true;
|
||||
}
|
||||
|
||||
// 6. 审核通过:继续处理领用明细 & 库存 & 领用单状态
|
||||
// 注意:requisition_item_id、item_id、return_qty 等字段在本次确认流程中不会被修改,
|
||||
// 所以可以直接使用 $existingRecord 中的值(都是只读字段)
|
||||
$requisitionItemId = (int)$existingRecord->requisition_item_id;
|
||||
$itemId = (int)$existingRecord->item_id;
|
||||
$returnQty = (int)$existingRecord->return_qty;
|
||||
$requisitionId = (int)$existingRecord->requisition_id;
|
||||
|
||||
if ($requisitionItemId <= 0 || $itemId <= 0 || $returnQty <= 0 || $requisitionId <= 0) {
|
||||
throw new BusinessException('归还申请记录缺少必要字段,无法处理');
|
||||
}
|
||||
|
||||
// 6.1 处理领用明细
|
||||
/** @var \app\model\InvRequisitionItem|null $item */
|
||||
$item = InvRequisitionItem::find($requisitionItemId);
|
||||
if (!$item) {
|
||||
throw new BusinessException('未找到对应的领用明细记录');
|
||||
}
|
||||
|
||||
$approvedQty = (int)($item->approved_qty ?? 0);
|
||||
$returnedQty = (int)($item->returned_qty ?? 0);
|
||||
|
||||
if ($approvedQty <= 0) {
|
||||
throw new BusinessException('该物品尚未审批出库,无法归还');
|
||||
}
|
||||
|
||||
if ($returnedQty + $returnQty > $approvedQty) {
|
||||
throw new BusinessException('归还数量超过已借数量,无法确认');
|
||||
}
|
||||
|
||||
$item->returned_qty = $returnedQty + $returnQty;
|
||||
if ($item->returned_qty >= $approvedQty) {
|
||||
$item->is_finished = 1;
|
||||
}
|
||||
$item->updated_at = time();
|
||||
|
||||
if ($item->save() === false) {
|
||||
throw new BusinessException('更新领用明细失败');
|
||||
}
|
||||
|
||||
// 6.2 处理库存(入库)
|
||||
(new InvItemService())->stockIn($itemId, $returnQty);
|
||||
|
||||
// 6.3 检查整张单是否已全部归还(只看非耗材)
|
||||
$notFinishedCount = InvRequisitionItem::query()
|
||||
->where('requisition_id', $requisitionId)
|
||||
->where('is_consumable_snap', 0)
|
||||
->where('is_finished', 0)
|
||||
->count();
|
||||
|
||||
if ($notFinishedCount === 0) {
|
||||
// 全部非耗材都已归还
|
||||
$affected = InvRequisition::where('id', $requisitionId)->update([
|
||||
'is_returned' => 1,
|
||||
'status' => 17,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
|
||||
// Eloquent::update 返回的是影响行数,0 表示未更新到任何行
|
||||
if ($affected === 0) {
|
||||
throw new BusinessException('更新领用单状态失败,可能记录不存在');
|
||||
}
|
||||
}else{
|
||||
// 非耗材未全部归还
|
||||
$affected = InvRequisition::where('id', $requisitionId)->update([
|
||||
'status' => 16,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 7. 返回处理结果(保持你原来的 postProcess 习惯)
|
||||
return $this->postProcess(ucfirst($scene), $existingRecord);
|
||||
});
|
||||
|
||||
} catch (BusinessException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
throw new BusinessException("确认归还物品失败:" . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 物品领用归还确认
|
||||
*
|
||||
* @param Request $request
|
||||
* @param array $data
|
||||
* @param string $scene
|
||||
* @return array|bool
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function serviceReturnConfirm(Request $request, array $data, string $scene = 'returnConfirm')
|
||||
{
|
||||
try {
|
||||
if(empty($data['return_id'])) {
|
||||
$data['return_id'] = $data['requisition_item_return_id'];
|
||||
}
|
||||
$attachment = $request->post('attachment') ?? [];
|
||||
unset($data['attachment']);
|
||||
// 归还确认是业务顶层方法,这里统一使用事务
|
||||
return Db::transaction(function () use ($request, $data, $scene, $attachment) {
|
||||
|
||||
// 1. 当前操作人信息
|
||||
$userInfo = (new UserService())->getUserInfo($request->user->uid, '');
|
||||
if (empty($userInfo['user']['id']) || empty($userInfo['user']['name'])) {
|
||||
throw new BusinessException('无法获取当前用户信息');
|
||||
}
|
||||
|
||||
$handlerId = (int)$userInfo['user']['id'];
|
||||
$handlerName = (string)$userInfo['user']['name'];
|
||||
|
||||
$data['handler_id'] = $handlerId;
|
||||
$data['handler_name'] = $handlerName;
|
||||
|
||||
// 确认状态:1=通过,2=拒绝
|
||||
$status = isset($data['status']) ? (int)$data['status'] : null;
|
||||
if (!in_array($status, [1, 2], true)) {
|
||||
throw new BusinessException('非法的归还处理状态');
|
||||
}
|
||||
|
||||
// 2. 构建查询配置,定位到这条归还记录
|
||||
$returnService = new InvRequisitionReturnService();
|
||||
$return = InvRequisitionReturn::where('id', $data['return_id'])->first();
|
||||
if (!$return) {
|
||||
throw new BusinessException('未找到对应的领用归还记录');
|
||||
}
|
||||
$requisitionId = (int)$return->requisition_id;
|
||||
$existingStatus = (int)($return->status ?? 0);
|
||||
if (in_array($existingStatus, [1, 2], true)) {
|
||||
throw new BusinessException('该归还申请已经被处理过了');
|
||||
}
|
||||
$return->status = $status;
|
||||
$return->save();
|
||||
if($status == 1){
|
||||
//更改库存
|
||||
$returnItem = Db::table('inv_requisition_item_return')
|
||||
->where('return_id', $return->id)
|
||||
->get();
|
||||
foreach ($returnItem as $item) {
|
||||
//$invItem = InvItem::where('id', $item->item_id)->first();
|
||||
(new InvItemService())->stockIn($item->item_id,$item->return_qty,['change_type'=>InvStockLog::CHANGE_TYPE_IN_RETURN,
|
||||
'biz_type'=>InvStockLog::BIZ_TYPE_RETURN,]);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存归还审核附件
|
||||
foreach ($attachment as $attac) {
|
||||
Db::table('inv_requisition_return_approve_attachment')->insertGetId([
|
||||
'requisition_id' => $requisitionId,
|
||||
'return_id' => $data['return_id'],
|
||||
'file_name' => $attac['original_name'],
|
||||
'file_path' => $attac['file_path'],
|
||||
'created_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
//查询归还明细
|
||||
$itemReturns = InvRequisitionItemReturn::where('return_id', $data['return_id'])->get();
|
||||
foreach ($itemReturns as $itemReturn) {
|
||||
$itemReturn->status = $status;
|
||||
$itemReturn->handle_remark = $data['handle_remark'];
|
||||
$itemReturn->save();
|
||||
|
||||
// 5. 若是拒绝,直接跳出(不改库存、不改已归还数量)
|
||||
if ($status === 2) {
|
||||
// 拒绝:这里你返回 true 或者返回更新后的记录都可以
|
||||
continue;
|
||||
}
|
||||
|
||||
// 6. 审核通过:继续处理领用明细 & 库存 & 领用单状态
|
||||
// 注意:requisition_item_id、item_id、return_qty 等字段在本次确认流程中不会被修改,
|
||||
// 所以可以直接使用 $existingRecord 中的值(都是只读字段)
|
||||
$requisitionItemId = (int)$itemReturn->requisition_item_id;
|
||||
$itemId = (int)$itemReturn->item_id;
|
||||
$returnQty = (int)$itemReturn->return_qty;
|
||||
|
||||
if ($requisitionItemId <= 0 || $itemId <= 0 || $returnQty <= 0 || $requisitionId <= 0) {
|
||||
throw new BusinessException('归还申请记录缺少必要字段,无法处理');
|
||||
}
|
||||
|
||||
// 6.2 处理领用明细
|
||||
/** @var \app\model\InvRequisitionItem|null $item */
|
||||
$item = InvRequisitionItem::find($requisitionItemId);
|
||||
if (!$item) {
|
||||
throw new BusinessException('未找到对应的领用明细记录');
|
||||
}
|
||||
|
||||
$approvedQty = (int)($item->approved_qty ?? 0);
|
||||
$returnedQty = (int)($item->returned_qty ?? 0);
|
||||
|
||||
if ($approvedQty <= 0) {
|
||||
throw new BusinessException('该物品尚未审批出库,无法归还');
|
||||
}
|
||||
|
||||
if ($returnedQty + $returnQty > $approvedQty) {
|
||||
throw new BusinessException('归还数量超过已借数量,无法确认');
|
||||
}
|
||||
|
||||
$item->returned_qty = $returnedQty + $returnQty;
|
||||
if ($item->returned_qty >= $approvedQty) {
|
||||
$item->is_finished = 1;
|
||||
}
|
||||
$item->updated_at = time();
|
||||
|
||||
if ($item->save() === false) {
|
||||
throw new BusinessException('更新领用明细失败');
|
||||
}
|
||||
|
||||
// 6.3 处理库存(入库)
|
||||
(new InvItemService())->stockIn($itemId, $returnQty);
|
||||
}
|
||||
// 5. 若是拒绝,直接返回(不修改领用申请状态)
|
||||
if ($status === 2) {
|
||||
// 拒绝:这里你返回 true 或者返回更新后的记录都可以
|
||||
return $return;
|
||||
}
|
||||
|
||||
// 6.3 检查整张单是否已全部归还(只看非耗材)
|
||||
$notFinishedCount = InvRequisitionItem::query()
|
||||
->where('requisition_id', $requisitionId)
|
||||
->where('is_consumable_snap', 0)
|
||||
->where('is_finished', 0)
|
||||
->count();
|
||||
if ($notFinishedCount === 0) {
|
||||
// 全部非耗材都已归还
|
||||
$affected = InvRequisition::where('id', $requisitionId)->update([
|
||||
'is_returned' => 1,
|
||||
'status' => 17,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
|
||||
// Eloquent::update 返回的是影响行数,0 表示未更新到任何行
|
||||
if ($affected === 0) {
|
||||
throw new BusinessException('更新领用单状态失败,可能记录不存在');
|
||||
}
|
||||
}else{
|
||||
// 非耗材未全部归还
|
||||
$affected = InvRequisition::where('id', $requisitionId)->update([
|
||||
'status' => 16,
|
||||
'updated_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 7. 返回处理结果(保持你原来的 postProcess 习惯)
|
||||
//return $this->postProcess(ucfirst($scene), $existingRecord);
|
||||
return $return;
|
||||
});
|
||||
|
||||
} catch (BusinessException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
throw new BusinessException("确认归还物品失败:" . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建查询配置
|
||||
*
|
||||
* @param Request $request
|
||||
* @param string $scene
|
||||
* @return array
|
||||
*/
|
||||
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
|
||||
{
|
||||
$p = $request->all();
|
||||
|
||||
$config = [];
|
||||
$config['model'] = $this->modelClass;
|
||||
switch ($scene) {
|
||||
case 'returnConfirm':
|
||||
$config['where']['id'] = $p['requisition_item_return_id'];
|
||||
break;
|
||||
case 'index':
|
||||
// if(isset($p['status']) && $p['status']!=''){
|
||||
// $config['where']['status'] = $p['status'];
|
||||
// }
|
||||
// if(isset($p['handler_id']) && $p['handler_id']=='self'){
|
||||
// $config['where']['applicant_id'] = $request->user->uid;
|
||||
// }else{
|
||||
// $config['where']['handler_id'] = $request->user->uid;
|
||||
// }
|
||||
// $config['order']['id'] = 'desc';
|
||||
break;
|
||||
case 'show':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
case 'store':
|
||||
break;
|
||||
case 'update':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
case 'destroy':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/** 分页列表 */
|
||||
public function serviceList(Request $request, string $scene = 'index')
|
||||
{
|
||||
$InvRequisitionReturn = new InvRequisitionReturnService();
|
||||
return $InvRequisitionReturn->serviceList($request, $scene);
|
||||
}
|
||||
|
||||
/** 查看单条 */
|
||||
public function serviceShow(Request $request, string $scene = 'show')
|
||||
{
|
||||
$id = $request->get('id');
|
||||
$return = InvRequisitionReturn::where('id', $id)->first();
|
||||
$return['item_return'] = InvRequisitionItemReturn::where('return_id', $return['id'])->orderBy('id', 'desc')->get();
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成申请单号
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function generateOrderNo(): string
|
||||
{
|
||||
return 'RR' . date('Ymd') . strtoupper(substr(uniqid(), -6));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\validate\InvRequisitionItemValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\InvRequisitionItem;
|
||||
|
||||
class InvRequisitionItemService
|
||||
{
|
||||
|
||||
use CrudServiceTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisitionItem::class;
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected $validator = InvRequisitionItemValidate::class;
|
||||
|
||||
/**
|
||||
* 获取验证器
|
||||
*/
|
||||
public function getValidator()
|
||||
{
|
||||
return new $this->validator;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\validate\InvRequisitionReturnValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\InvRequisitionReturn;
|
||||
use support\exception\BusinessException;
|
||||
use support\Request;
|
||||
|
||||
class InvRequisitionReturnService
|
||||
{
|
||||
|
||||
use CrudServiceTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisitionReturn::class;
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected $validator = InvRequisitionReturnValidate::class;
|
||||
|
||||
/**
|
||||
* 获取验证器
|
||||
*/
|
||||
public function getValidator()
|
||||
{
|
||||
return new $this->validator;
|
||||
}
|
||||
|
||||
public function serviceReturnConfirm(Request$request)
|
||||
{
|
||||
$status = $request->post('status');
|
||||
if(in_array($status,[1,2])){
|
||||
throw new BusinessException("状态不正确");
|
||||
}
|
||||
$requisitionReturn = InvRequisitionReturn::where(['id' => $request['id']])->first();
|
||||
if (!$requisitionReturn && $requisitionReturn->handler_id == $request->user->uid) {
|
||||
$requisitionReturn->status = $status;
|
||||
return $requisitionReturn->save();
|
||||
}else{
|
||||
throw new BusinessException("审核示例不正确或非物品管理员");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 构建查询配置
|
||||
*
|
||||
* @param Request $request
|
||||
* @param string $scene
|
||||
* @return array
|
||||
*/
|
||||
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
|
||||
{
|
||||
$p = $request->all();
|
||||
|
||||
$config = [];
|
||||
$config['model'] = $this->modelClass;
|
||||
switch ($scene) {
|
||||
case 'returnConfirm':
|
||||
$config['where']['id'] = $p['requisition_item_return_id'];
|
||||
break;
|
||||
case 'index':
|
||||
if(isset($p['status']) && $p['status']!=''){
|
||||
$config['where']['status'] = $p['status'];
|
||||
}
|
||||
if (isset($p['handler_id']) && $p['handler_id'] == 'self') {
|
||||
$config['where']['applicant_id'] = $request->user->uid;
|
||||
} else {
|
||||
$config['where']['handler_id'] = $request->user->uid;
|
||||
}
|
||||
$config['order']['id'] = 'desc';
|
||||
break;
|
||||
case 'show':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
case 'store':
|
||||
break;
|
||||
case 'update':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
case 'destroy':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\model\InvItem;
|
||||
use app\common\model\InvStockLog;
|
||||
use app\common\validate\InvRequisitionValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\InvRequisition;
|
||||
use app\common\model\InvRequisitionAttachment;
|
||||
use app\common\model\InvRequisitionItem;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use support\Request;
|
||||
use support\Db;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
class InvRequisitionService
|
||||
{
|
||||
|
||||
use CrudServiceTrait;
|
||||
|
||||
/**
|
||||
* 指定当前要操作的模型
|
||||
*/
|
||||
protected string $modelClass = InvRequisition::class;
|
||||
|
||||
/**
|
||||
* 验证器
|
||||
*/
|
||||
protected $validator = InvRequisitionValidate::class;
|
||||
|
||||
/**
|
||||
* 获取验证器
|
||||
*/
|
||||
public function getValidator()
|
||||
{
|
||||
return new $this->validator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 CrudUtil 查询配置
|
||||
*/
|
||||
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
|
||||
{
|
||||
$p = $request->all();
|
||||
|
||||
$config = [];
|
||||
$config['model'] = $this->modelClass;
|
||||
switch ($scene) {
|
||||
case 'default':
|
||||
$config['where']['status'] = 1;
|
||||
$config['order']['sort'] = 'desc';
|
||||
$config['order']['id'] = 'desc';
|
||||
break;
|
||||
case 'index':
|
||||
if(isset($p['status']) && $p['status']){
|
||||
$config['where']['status'] = $p['status'];
|
||||
}
|
||||
if (isset($p['applicant_id']) && $p['applicant_id'] == 'self') { //自己的领用申请
|
||||
$config['where']['applicant_id'] = $request->user->uid;
|
||||
} elseif (isset($p['applicant_id']) && $p['applicant_id']) { //指定人员的申请
|
||||
$config['where']['applicant_id'] = $p['applicant_id'];
|
||||
}
|
||||
/* else { //审批人对应的记录
|
||||
$config['with'] = ['approval'];
|
||||
$config['where']['approval.approver_id'] = $request->user->uid;
|
||||
//$config['where']['approval.action'] = 0;
|
||||
}*/
|
||||
if(isset($p['phase']) && $p['phase'] == 'approve'){//借用审批中
|
||||
$config['where']['status'] = ['in',[10,11]];
|
||||
if(isset($p['scene']) && $p['scene'] == 'approval'){
|
||||
$config['with'] = ['approval'];
|
||||
$config['where']['approval.action'] = 0;
|
||||
}
|
||||
}elseif(isset($p['phase']) && $p['phase'] == 'repaid'){//已全部归还
|
||||
$config['where']['status'] = 17;
|
||||
}elseif(isset($p['phase']) && $p['phase'] == 'unreturned'){ //未归还
|
||||
$config['where']['status'] = ['in',[11,12,13,14,15,16,18,19]];
|
||||
}
|
||||
//审核列表
|
||||
elseif(isset($p['phase']) && $p['phase'] == 'pending'){ //待审核
|
||||
$config['where']['status'] = ['in',[10,11]];
|
||||
$config['with'] = ['approval'];
|
||||
$config['where']['approval.approver_id'] = $request->user->uid;
|
||||
$config['where']['approval.action'] = 0;
|
||||
}elseif(isset($p['phase']) && $p['phase'] == 'approved'){ //已通过
|
||||
$config['where']['status'] = ['in',[11,12,14,15,16,17,18,19]];
|
||||
$config['with'] = ['approval'];
|
||||
$config['where']['approval.approver_id'] = $request->user->uid;
|
||||
$config['where']['approval.action'] = [">",0];
|
||||
}elseif(isset($p['phase']) && $p['phase'] == 'refuse'){ //拒绝,未通过
|
||||
$config['where']['status'] = ['in',[13]];
|
||||
}
|
||||
$config['order']['id'] = 'desc';
|
||||
break;
|
||||
case 'show':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
case 'store':
|
||||
break;
|
||||
case 'update':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
case 'destroy':
|
||||
$config['where']['id'] = $p['id'];
|
||||
break;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建物品申请 前置
|
||||
*
|
||||
* @param array $data
|
||||
* @param Request $request
|
||||
* @return array
|
||||
*/
|
||||
public function beforeServiceStore($data, Request $request): array
|
||||
{
|
||||
$user = (new UserService())->getUserInfo($request->user->uid, '');
|
||||
|
||||
if (empty($user['user']['id']) || empty($user['user']['name'])) {
|
||||
throw new BusinessException('无法获取当前用户信息');
|
||||
}
|
||||
|
||||
$data['order_no'] = $this->generateOrderNo();
|
||||
$data['applicant_id'] = (int)$user['user']['id'];
|
||||
$data['applicant_name'] = (string)$user['user']['name'];
|
||||
$data['leader_id'] = $user['user']['report_to'];
|
||||
$data['status'] = 10;
|
||||
|
||||
//查询物品管理人是否是一个
|
||||
if(count($data['items'])>=2){
|
||||
$manage_user = [];
|
||||
foreach ($data['items'] as $item) {
|
||||
$manage_user[] = InvItem::where('id',$item['item_id'])->value('manage_user_id');
|
||||
}
|
||||
$manage_user = array_unique($manage_user);
|
||||
if(count($manage_user)>1){
|
||||
throw new BusinessException("物品管理员不是同一人");
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理物品申请
|
||||
*
|
||||
* @param Request $request
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
public function serviceStore(Request $request, array $data, string $scene = '')
|
||||
{
|
||||
try {
|
||||
// 关联表增加事务处理
|
||||
return Db::transaction(function () use ($request, $data) {
|
||||
$data = $this->preProcess('Store', $data, $request);
|
||||
$dl = $this->getDataLimitFromRequest($request);
|
||||
$created = $this->logicCreate($data, $dl);
|
||||
// 处理物品申请项
|
||||
$this->processRequisitionItems($created, $data['items']);
|
||||
// 处理物品申请附件
|
||||
$this->processRequisitionAttachmentItems($created, $data['attachment']);
|
||||
$res = $this->postProcess('Store', $created);
|
||||
|
||||
$manage_user_id = InvItem::where('id',$data['items'][0]['item_id'])->value('manage_user_id');
|
||||
$approval = [
|
||||
'id' => $res['id'], //申请单id
|
||||
'applicant_id' => $res['applicant_id'], //申请人id
|
||||
'manage_user_id' => $manage_user_id,
|
||||
];
|
||||
$this->startApproval($approval);
|
||||
|
||||
return $res;
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new BusinessException("创建物品申请失败:".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动审批
|
||||
*/
|
||||
public function startApproval($requisition)
|
||||
{
|
||||
// 启动审批
|
||||
//$appro = new \app\common\utils\approval\service\ApprovalFlowService();
|
||||
$appro = new \app\common\service\InvRequisitionApproveService();
|
||||
$formData = [
|
||||
'manage_user_id' => $requisition['manage_user_id'],
|
||||
];
|
||||
return $appro->start('inv_requisition', 'inv_requisition', $requisition['id'], $formData, $requisition['applicant_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新物品申请
|
||||
*
|
||||
* @param Request $request
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
public function serviceUpdate(Request $request, array $data, string $scene = '')
|
||||
{
|
||||
try {
|
||||
// 关联表增加事务处理
|
||||
return Db::transaction(function () use ($request, $data) {
|
||||
$data = $this->preProcess('Update', $data, $request);
|
||||
$config = $this->buildQueryConfig($request, 'update');
|
||||
|
||||
// 获取申请项/附件
|
||||
$items = $data['items'];
|
||||
$attachment = $data['attachment'];
|
||||
unset($data['items']);
|
||||
unset($data['attachment']);
|
||||
|
||||
// 执行更新操作
|
||||
$this->logicUpdateBy($config, $data);
|
||||
|
||||
// 获取更新后的完整记录
|
||||
$updated = $this->logicShow($config);
|
||||
|
||||
// 重新创建新的申请项
|
||||
$this->processRequisitionItems((object)$updated, $items, true);
|
||||
|
||||
// 重新创建新的附件
|
||||
$this->processRequisitionAttachmentItems((object)$updated, $attachment, true);
|
||||
|
||||
return $this->postProcess('Update', $updated);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
throw new BusinessException("更新物品申请失败:" . $e->getMessage()."|".$e->getLine()."|".$e->getFile());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理物品申请项
|
||||
*
|
||||
* @param Model $created
|
||||
* @param array $items
|
||||
* @return void
|
||||
*/
|
||||
private function processRequisitionItems(Model $created, array $items, bool $isUpdate = false): void
|
||||
{
|
||||
if ($isUpdate) {
|
||||
$this->clearRequisitionItems($created->id);
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
$invitem = InvItem::where('id',$item)->first();
|
||||
$itemData = [
|
||||
'requisition_id' => $created->id,
|
||||
'item_id' => $item['item_id'],
|
||||
'item_name_snap' => $item['item_name_snap'], // 修改:使用 snap 字段
|
||||
'spec_model_snap' => $item['spec_model_snap']??'', // 修改:使用 snap 字段
|
||||
'unit_snap' => $item['unit_snap']??'', // 修改:使用 snap 字段
|
||||
'is_consumable_snap' => $item['is_consumable_snap']?? ($invitem['is_consumable_snap']??0), // 修改:使用 snap 字段
|
||||
'request_qty' => (int) $item['request_qty'] ?? 1, // 动态获取数量,转为整型
|
||||
'approved_qty' => (int) $item['request_qty'] ?? 1, // 初始审批数量等于申请数量
|
||||
'remark' => $item['remark'] ?? ''
|
||||
];
|
||||
InvRequisitionItem::create($itemData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理物品申请项
|
||||
*
|
||||
* @param Model $created
|
||||
* @param array $items
|
||||
* @return void
|
||||
*/
|
||||
private function processRequisitionAttachmentItems(Model $created, array $attachment, bool $isUpdate = false): void
|
||||
{
|
||||
if ($isUpdate) {
|
||||
$this->clearRequisitionAttachmentItems($created->id);
|
||||
}
|
||||
|
||||
foreach ($attachment as $file) {
|
||||
$attachmentData = [
|
||||
'requisition_id' => $created->id,
|
||||
'file_name' => $file['original_name'],
|
||||
'file_path' => $file['file_path']
|
||||
];
|
||||
InvRequisitionAttachment::create($attachmentData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除申请项
|
||||
*
|
||||
* @param int $requisitionId
|
||||
* @return void
|
||||
*/
|
||||
public function clearRequisitionItems(int $requisitionId): void
|
||||
{
|
||||
InvRequisitionItem::where('requisition_id', $requisitionId)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除申请项
|
||||
*
|
||||
* @param int $requisitionId
|
||||
* @return void
|
||||
*/
|
||||
public function clearRequisitionAttachmentItems(int $requisitionId): void
|
||||
{
|
||||
InvRequisitionAttachment::where('requisition_id', $requisitionId)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成申请单号
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function generateOrderNo(): string
|
||||
{
|
||||
return 'RQ' . date('Ymd') . strtoupper(substr(uniqid(), -6));
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码领取物品
|
||||
*
|
||||
* @param Request $request
|
||||
* @param array $data
|
||||
* @return array
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function qrClaim(Request $request, array $data): array
|
||||
{
|
||||
return Db::transaction(function () use ($request, $data) {
|
||||
$itemId = (int)($data['item_id'] ?? 0);
|
||||
$qty = (int)($data['qty'] ?? 1);
|
||||
|
||||
if ($itemId <= 0) {
|
||||
throw new BusinessException('物品ID无效');
|
||||
}
|
||||
if ($qty <= 0) {
|
||||
throw new BusinessException('领取数量必须大于0');
|
||||
}
|
||||
|
||||
// 获取物品信息
|
||||
$item = InvItem::find($itemId);
|
||||
if (!$item) {
|
||||
throw new BusinessException('物品不存在');
|
||||
}
|
||||
|
||||
// 检查是否允许扫码领取
|
||||
if (!$item->allow_qr_claim) {
|
||||
throw new BusinessException('该物品不允许扫码领取');
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
$user = (new UserService())->getUserInfo($request->user->uid, '');
|
||||
$applicantId = (int)$user['user']['id'];
|
||||
$applicantName = (string)$user['user']['name'];
|
||||
|
||||
// 检查扫码领取数量限制
|
||||
if ($item->qr_code_num > 0) {
|
||||
// 查询该用户已扫码领取该物品的数量
|
||||
$claimedQty = InvRequisitionItem::whereHas('requisition', function ($q) use ($applicantId) {
|
||||
$q->where('applicant_id', $applicantId)
|
||||
->where('claim_type', InvRequisition::CLAIM_TYPE_QR);
|
||||
})->where('item_id', $itemId)
|
||||
->sum('approved_qty');
|
||||
|
||||
if (($claimedQty + $qty) > $item->qr_code_num) {
|
||||
throw new BusinessException("扫码领取数量超限,每人最多可领取 {$item->qr_code_num} {$item->unit},已领取 {$claimedQty} {$item->unit}");
|
||||
}
|
||||
}
|
||||
|
||||
// 检查库存
|
||||
if ($item->stock_qty < $qty) {
|
||||
throw new BusinessException('库存不足,当前库存: ' . $item->stock_qty);
|
||||
}
|
||||
|
||||
// 创建领用申请记录(直接完成)
|
||||
$requisition = InvRequisition::create([
|
||||
'order_no' => $this->generateOrderNo(),
|
||||
'applicant_company_id' => $user['user']['company_id'] ?? 0,
|
||||
'dept_id' => $user['user']['dept_id'] ?? 0,
|
||||
'applicant_id' => $applicantId,
|
||||
'applicant_name' => $applicantName,
|
||||
'leader_id' => $user['user']['report_to'] ?? 0,
|
||||
'manager_id' => $item->manage_user_id,
|
||||
'borrow_reason' => '扫码领取',
|
||||
'use_start_date' => date('Y-m-d', time()),
|
||||
'use_end_date' => date('Y-m-d', time()),
|
||||
'status' => 19, // 已审批(无需归还)
|
||||
'claim_type' => InvRequisition::CLAIM_TYPE_QR,
|
||||
'qr_claim_time' => time(),
|
||||
'remark' => $data['remark'] ?? '扫码领取',
|
||||
]);
|
||||
|
||||
// 创建领用明细
|
||||
InvRequisitionItem::create([
|
||||
'requisition_id' => $requisition->id,
|
||||
'item_id' => $itemId,
|
||||
'item_name_snap' => $item->item_name,
|
||||
'spec_model_snap' => $item->spec_model,
|
||||
'unit_snap' => $item->unit,
|
||||
'is_consumable_snap' => $item->is_consumable,
|
||||
'request_qty' => $qty,
|
||||
'approved_qty' => $qty,
|
||||
'remark' => '扫码领取',
|
||||
]);
|
||||
|
||||
// 扣减库存
|
||||
(new InvItemService())->stockOut($itemId, $qty, false, [
|
||||
'biz_type' => InvStockLog::BIZ_TYPE_QR_CLAIM,
|
||||
'biz_no' => $requisition->order_no,
|
||||
'operator_id' => $applicantId,
|
||||
'operator_name' => $applicantName,
|
||||
'remark' => '扫码领取',
|
||||
]);
|
||||
|
||||
return $requisition->toArray();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员代领(支持多人)
|
||||
*
|
||||
* @param Request $request
|
||||
* @param array $data
|
||||
* @return array
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function proxyClaim(Request $request, array $data): array
|
||||
{
|
||||
return Db::transaction(function () use ($request, $data) {
|
||||
$itemId = (int)($data['item_id'] ?? 0);
|
||||
$qty = (int)($data['qty'] ?? 1);
|
||||
$targetUserIds = $data['target_user_id'];
|
||||
|
||||
if ($itemId <= 0) {
|
||||
throw new BusinessException('物品ID无效');
|
||||
}
|
||||
if ($qty <= 0) {
|
||||
throw new BusinessException('领取数量必须大于0');
|
||||
}
|
||||
if (empty($targetUserIds)) {
|
||||
throw new BusinessException('请选择代领用户');
|
||||
}
|
||||
|
||||
// 获取物品信息
|
||||
$item = InvItem::find($itemId);
|
||||
if (!$item) {
|
||||
throw new BusinessException('物品不存在');
|
||||
}
|
||||
|
||||
$targetUserIds = explode(',', $targetUserIds);
|
||||
|
||||
// 获取操作人(管理员)信息
|
||||
$proxyUser = (new UserService())->getUserInfo($request->user->uid, '');
|
||||
$proxyUserId = (int)$proxyUser['user']['id'];
|
||||
$proxyUserName = (string)$proxyUser['user']['name'];
|
||||
|
||||
// 验证操作人是否是物品管理员
|
||||
if ($item->manage_user_id != $proxyUserId) {
|
||||
throw new BusinessException('您不是该物品的管理员,无权代领');
|
||||
}
|
||||
|
||||
// 计算总需求量
|
||||
$totalQty = $qty * count($targetUserIds);
|
||||
|
||||
// 检查库存
|
||||
if ($item->stock_qty < $totalQty) {
|
||||
throw new BusinessException('库存不足,当前库存: ' . $item->stock_qty . ',需要: ' . $totalQty);
|
||||
}
|
||||
|
||||
$createdRequisitions = [];
|
||||
$remark = $data['remark'] ?? '管理员代领';
|
||||
|
||||
// 为每个目标用户创建领用记录
|
||||
foreach ($targetUserIds as $targetUserId) {
|
||||
$targetUserId = (int)$targetUserId;
|
||||
if ($targetUserId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取目标用户信息
|
||||
$targetUser = (new UserService())->getUserInfo($targetUserId, '');
|
||||
if (!$targetUser['user']) {
|
||||
throw new BusinessException('用户ID ' . $targetUserId . ' 不存在');
|
||||
}
|
||||
|
||||
// 创建领用申请记录(直接完成)
|
||||
$requisition = InvRequisition::create([
|
||||
'order_no' => $this->generateOrderNo(),
|
||||
'applicant_company_id' => $targetUser['user']['company_id'] ?? 0,
|
||||
'dept_id' => $targetUser['user']['dept_id'] ?? 0,
|
||||
'applicant_id' => $targetUserId,
|
||||
'applicant_name' => $targetUser['user']['name'],
|
||||
'leader_id' => $targetUser['user']['report_to'] ?? 0,
|
||||
'manager_id' => $item->manage_user_id,
|
||||
'borrow_reason' => $data['borrow_reason'] ?? '管理员代领',
|
||||
'use_start_date' => date('Y-m-d', time()),
|
||||
'use_end_date' => date('Y-m-d', time()),
|
||||
'status' => 19, // 已审批(无需归还)
|
||||
'claim_type' => InvRequisition::CLAIM_TYPE_PROXY,
|
||||
'proxy_claim_user_id' => $proxyUserId,
|
||||
'proxy_claim_user_name' => $proxyUserName,
|
||||
'remark' => $remark,
|
||||
]);
|
||||
|
||||
// 创建领用明细
|
||||
InvRequisitionItem::create([
|
||||
'requisition_id' => $requisition->id,
|
||||
'item_id' => $itemId,
|
||||
'item_name_snap' => $item->item_name,
|
||||
'spec_model_snap' => $item->spec_model,
|
||||
'unit_snap' => $item->unit,
|
||||
'is_consumable_snap' => $item->is_consumable,
|
||||
'request_qty' => $qty,
|
||||
'approved_qty' => $qty,
|
||||
'remark' => $remark,
|
||||
]);
|
||||
|
||||
$createdRequisitions[] = $requisition->toArray();
|
||||
}
|
||||
|
||||
// 一次性扣减总库存
|
||||
(new InvItemService())->stockOut($itemId, $totalQty, false, [
|
||||
'biz_type' => InvStockLog::BIZ_TYPE_PROXY_CLAIM,
|
||||
'biz_no' => 'PROXY_' . time(),
|
||||
'operator_id' => $proxyUserId,
|
||||
'operator_name' => $proxyUserName,
|
||||
'remark' => '管理员代领(' . count($targetUserIds) . '人×' . $qty . ')',
|
||||
]);
|
||||
|
||||
return [
|
||||
'created_count' => count($createdRequisitions),
|
||||
'requisitions' => $createdRequisitions,
|
||||
'total_qty' => $totalQty,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询扫码/代领记录列表
|
||||
*
|
||||
* @param Request $request
|
||||
* @return array
|
||||
*/
|
||||
public function qrClaimsList(Request $request): array
|
||||
{
|
||||
$query = InvRequisition::query()->with(['items']);
|
||||
|
||||
// 只查询扫码领取和代领记录
|
||||
$query->whereIn('claim_type', [
|
||||
InvRequisition::CLAIM_TYPE_QR,
|
||||
InvRequisition::CLAIM_TYPE_PROXY
|
||||
]);
|
||||
|
||||
// 按申请人筛选(移动端"我的记录"功能)
|
||||
if ($request->get('applicant_id') === 'self') {
|
||||
$query->where('applicant_id', $request->user->uid);
|
||||
} elseif (!empty($request->get('applicant_id'))) {
|
||||
$query->where('applicant_id', $request->get('applicant_id'));
|
||||
}
|
||||
|
||||
// 按领取方式筛选
|
||||
if (!empty($request->get('claim_type'))) {
|
||||
$query->where('claim_type', $request->get('claim_type'));
|
||||
}
|
||||
|
||||
// 按代领操作人筛选
|
||||
if (!empty($request->get('proxy_user_id'))) {
|
||||
$query->where('proxy_claim_user_id', $request->get('proxy_user_id'));
|
||||
}
|
||||
|
||||
// 按物品ID筛选
|
||||
if (!empty($request->get('item_id'))) {
|
||||
$query->whereHas('items', function ($q) use ($request) {
|
||||
$q->where('item_id', $request->get('item_id'));
|
||||
});
|
||||
}
|
||||
|
||||
// 时间范围筛选
|
||||
if (!empty($request->get('start_time'))) {
|
||||
$query->where('created_at', '>=', strtotime($request->get('start_time')));
|
||||
}
|
||||
if (!empty($request->get('end_time'))) {
|
||||
$query->where('created_at', '<=', strtotime($request->get('end_time')));
|
||||
}
|
||||
|
||||
// 排序
|
||||
$query->orderBy('id', 'desc');
|
||||
|
||||
// 分页
|
||||
$perPage = $request->get('size', 20);
|
||||
$page = $request->get('current', 1);
|
||||
|
||||
$result = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
return [
|
||||
'records' => $result->getCollection()->map(function ($req) {
|
||||
return [
|
||||
'id' => $req->id,
|
||||
'order_no' => $req->order_no,
|
||||
'applicant_id' => $req->applicant_id,
|
||||
'applicant_name' => $req->applicant_name,
|
||||
'claim_type' => $req->claim_type,
|
||||
'claim_type_text' => InvRequisition::getClaimTypeText($req->claim_type),
|
||||
'qr_claim_time' => $req->qr_claim_time ? date('Y-m-d H:i:s', $req->qr_claim_time) : null,
|
||||
'proxy_claim_user_id' => $req->proxy_claim_user_id,
|
||||
'proxy_claim_user_name' => $req->proxy_claim_user_name,
|
||||
'status' => $req->status,
|
||||
'borrow_reason' => $req->borrow_reason,
|
||||
'remark' => $req->remark,
|
||||
'created_at' => $req->created_at ? $req->created_at->format('Y-m-d H:i:s') : null,
|
||||
'items' => $req->items->map(function ($item) {
|
||||
return [
|
||||
'item_id' => $item->item_id,
|
||||
'item_name_snap' => $item->item_name_snap,
|
||||
'approved_qty' => $item->approved_qty,
|
||||
];
|
||||
})->toArray(),
|
||||
];
|
||||
})->toArray(),
|
||||
'total' => $result->total(),
|
||||
'current' => $result->currentPage(),
|
||||
'size' => $result->perPage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\model\InvStockLog;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
/**
|
||||
* 物品库存变化记录服务
|
||||
*/
|
||||
class InvStockLogService
|
||||
{
|
||||
/**
|
||||
* 记录库存变化
|
||||
*
|
||||
* @param array $data 日志数据
|
||||
* @return InvStockLog
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function record(array $data): InvStockLog
|
||||
{
|
||||
$required = ['item_id', 'change_type', 'change_qty', 'before_qty', 'after_qty'];
|
||||
foreach ($required as $field) {
|
||||
if (!isset($data[$field])) {
|
||||
throw new BusinessException("库存日志缺少必需字段: {$field}");
|
||||
}
|
||||
}
|
||||
|
||||
return InvStockLog::create([
|
||||
'company_id' => $data['company_id'] ?? 0,
|
||||
'item_id' => $data['item_id'],
|
||||
'item_name_snap' => $data['item_name_snap'] ?? null,
|
||||
'change_type' => $data['change_type'],
|
||||
'change_qty' => $data['change_qty'],
|
||||
'before_qty' => $data['before_qty'],
|
||||
'after_qty' => $data['after_qty'],
|
||||
'biz_type' => $data['biz_type'] ?? null,
|
||||
'biz_no' => $data['biz_no'] ?? null,
|
||||
'operator_id' => $data['operator_id'] ?? null,
|
||||
'operator_name' => $data['operator_name'] ?? null,
|
||||
'remark' => $data['remark'] ?? null,
|
||||
'updated_at' => time(),
|
||||
'created_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询库存变化记录列表
|
||||
*
|
||||
* @param array $params 查询参数
|
||||
* @return array
|
||||
*/
|
||||
public function getList(array $params): array
|
||||
{
|
||||
$query = InvStockLog::query()->with(['item']);
|
||||
|
||||
// 物品ID筛选
|
||||
if (!empty($params['item_id'])) {
|
||||
$query->where('item_id', $params['item_id']);
|
||||
}
|
||||
|
||||
// 变动类型筛选
|
||||
if (!empty($params['change_type'])) {
|
||||
$query->where('change_type', $params['change_type']);
|
||||
}
|
||||
|
||||
// 业务类型筛选
|
||||
if (!empty($params['biz_type'])) {
|
||||
$query->where('biz_type', $params['biz_type']);
|
||||
}
|
||||
|
||||
// 单据号筛选
|
||||
if (!empty($params['biz_no'])) {
|
||||
$query->where('biz_no', 'like', '%' . $params['biz_no'] . '%');
|
||||
}
|
||||
|
||||
// 时间范围筛选
|
||||
if (!empty($params['start_time'])) {
|
||||
$query->where('created_at', '>=', strtotime($params['start_time']));
|
||||
}
|
||||
if (!empty($params['end_time'])) {
|
||||
$query->where('created_at', '<=', strtotime($params['end_time']));
|
||||
}
|
||||
|
||||
// 排序
|
||||
$query->orderBy('id', 'desc');
|
||||
|
||||
// 分页
|
||||
$perPage = $params['size'] ?? 20;
|
||||
$page = $params['current'] ?? 1;
|
||||
|
||||
$result = $query->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
return [
|
||||
'records' => $result->getCollection()->map(function ($log) {
|
||||
return [
|
||||
'id' => $log->id,
|
||||
'item_id' => $log->item_id,
|
||||
'item_name' => $log->item_name_snap,
|
||||
'change_type' => $log->change_type,
|
||||
'change_type_text' => InvStockLog::getChangeTypeText($log->change_type),
|
||||
'change_qty' => $log->change_qty,
|
||||
'before_qty' => $log->before_qty,
|
||||
'after_qty' => $log->after_qty,
|
||||
'biz_type' => $log->biz_type,
|
||||
'biz_type_text' => $log->biz_type ? InvStockLog::getBizTypeText($log->biz_type) : null,
|
||||
'biz_no' => $log->biz_no,
|
||||
'operator_id' => $log->operator_id,
|
||||
'operator_name' => $log->operator_name,
|
||||
'remark' => $log->remark,
|
||||
'created_at' => date('Y-m-d H:i:s', is_int($log->created_at)?$log->created_at:strtotime($log->created_at)),
|
||||
];
|
||||
})->toArray(),
|
||||
'total' => $result->total(),
|
||||
'current' => $result->currentPage(),
|
||||
'size' => $result->perPage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -57,42 +57,6 @@ class OssService
|
||||
return $this->ossClient->signUrl($this->bucket, $objectKey, $expireTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件数组的 url 重签为新的私有访问地址(响应期调用,防止历史签名过期)。
|
||||
* 兼容三种存量格式:完整签名URL / 完整未签名URL / 纯对象键。
|
||||
* @param array $attachments [{url, original_name}, ...]
|
||||
* @param int $expireTime
|
||||
* @return array
|
||||
*/
|
||||
public static function resignAttachmentUrls(array $attachments, int $expireTime = 604800): array
|
||||
{
|
||||
foreach ($attachments as &$a) {
|
||||
if (empty($a['url']) || !is_string($a['url'])) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$url = $a['url'];
|
||||
if (strpos($url, 'http') === 0) {
|
||||
$path = (string)parse_url($url, PHP_URL_PATH);
|
||||
if ($path === '') {
|
||||
continue;
|
||||
}
|
||||
$key = ltrim(urldecode($path), '/');
|
||||
} else {
|
||||
$key = ltrim($url, '/'); // 已是对象键
|
||||
}
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$a['url'] = (new self())->generatePrivateUrl($key, $expireTime);
|
||||
} catch (\Throwable $e) {
|
||||
// 重签失败保留原样
|
||||
}
|
||||
}
|
||||
unset($a);
|
||||
return $attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 OSS 上的文件
|
||||
* @param string $objectKey OSS上存储的对象名称
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use support\exception\BusinessException;
|
||||
|
||||
/**
|
||||
* 二维码生成服务
|
||||
*/
|
||||
class QrCodeService
|
||||
{
|
||||
/**
|
||||
* 生成物品二维码
|
||||
*
|
||||
* @param int $itemId 物品ID
|
||||
* @param string $appUrl 应用访问地址
|
||||
* @return string 二维码内容URL
|
||||
*/
|
||||
public function generateItemQrCode(int $itemId, string $appUrl): string
|
||||
{
|
||||
//return rtrim($appUrl, '/') . '/mobile/inv/claim/' . $itemId;
|
||||
return "https://mweb-fdesk.hnxinyouhe.com/pages/scan/external-scan?id=" . $itemId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码图片并上传到 OSS
|
||||
*
|
||||
* @param int $itemId 物品ID
|
||||
* @param string $content 二维码内容
|
||||
* @param string $fileName 文件名
|
||||
* @return array 返回OSS路径和完整URL
|
||||
* @throws BusinessException
|
||||
*/
|
||||
public function generateAndUploadQrCode(int $itemId, string $content, string $fileName): array
|
||||
{
|
||||
// 使用 endroid/qr-code 库生成二维码
|
||||
// 如果未安装,使用 API 方式生成
|
||||
|
||||
$tempPath = $this->generateQrCodeImage($content, $fileName);
|
||||
|
||||
if (!$tempPath) {
|
||||
throw new BusinessException('二维码生成失败');
|
||||
}
|
||||
|
||||
try {
|
||||
// 上传到 OSS
|
||||
$ossService = new OssService();
|
||||
$ossPath = 'qr/' . $fileName;
|
||||
$ossUrl = $ossService->uploadImage($tempPath, $ossPath);
|
||||
|
||||
// 删除临时文件
|
||||
if (file_exists($tempPath)) {
|
||||
unlink($tempPath);
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $ossPath,
|
||||
'url' => $ossUrl,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
// 删除临时文件
|
||||
if (file_exists($tempPath)) {
|
||||
unlink($tempPath);
|
||||
}
|
||||
throw new BusinessException('二维码上传失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成二维码图片(使用 QR Server API 或本地库)
|
||||
*
|
||||
* @param string $content 二维码内容
|
||||
* @param string $fileName 文件名
|
||||
* @return string 临时文件路径,失败返回 false
|
||||
*/
|
||||
private function generateQrCodeImage(string $content, string $fileName)
|
||||
{
|
||||
$tempDir = runtime_path() . '/qr_temp/';
|
||||
if (!is_dir($tempDir)) {
|
||||
mkdir($tempDir, 0755, true);
|
||||
}
|
||||
|
||||
$tempPath = $tempDir . $fileName;
|
||||
|
||||
// 方式1: 使用 endroid/qr-code 库(推荐)
|
||||
if (class_exists('\Endroid\QrCode\QrCode')) {
|
||||
return $this->generateWithEndroid($content, $tempPath);
|
||||
}
|
||||
|
||||
// 方式2: 使用在线 API(备用方案)
|
||||
return $this->generateWithApi($content, $tempPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 endroid/qr-code 生成二维码
|
||||
*/
|
||||
private function generateWithEndroid(string $content, string $tempPath): string
|
||||
{
|
||||
try {
|
||||
// 5.x 版本使用静态工厂方法
|
||||
if (method_exists(\Endroid\QrCode\QrCode::class, 'create')) {
|
||||
$qrCode = \Endroid\QrCode\QrCode::create($content)
|
||||
->setSize(300)
|
||||
->setMargin(10);
|
||||
} else {
|
||||
// 4.x 及以下版本
|
||||
$qrCode = new \Endroid\QrCode\QrCode($content);
|
||||
if (method_exists($qrCode, 'setSize')) {
|
||||
$qrCode->setSize(300);
|
||||
}
|
||||
if (method_exists($qrCode, 'setMargin')) {
|
||||
$qrCode->setMargin(10);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 Writer
|
||||
if (class_exists('\Endroid\QrCode\Writer\PngWriter')) {
|
||||
$writer = new \Endroid\QrCode\Writer\PngWriter();
|
||||
} else {
|
||||
// 旧版本可能使用不同的命名空间
|
||||
$writer = new \Endroid\QrCode\Writer();
|
||||
}
|
||||
|
||||
$result = $writer->write($qrCode);
|
||||
|
||||
// 保存文件
|
||||
if (method_exists($result, 'getString')) {
|
||||
file_put_contents($tempPath, $result->getString());
|
||||
} else {
|
||||
// 更旧版本可能直接返回字符串
|
||||
file_put_contents($tempPath, $result);
|
||||
}
|
||||
|
||||
return $tempPath;
|
||||
} catch (\Throwable $e) {
|
||||
// 如果 endroid 库使用失败,回退到在线 API
|
||||
error_log('endroid/qr-code 生成失败: ' . $e->getMessage() . ',使用在线 API 备用方案');
|
||||
return $this->generateWithApi($content, $tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用在线 API 生成二维码(备用方案)
|
||||
*/
|
||||
private function generateWithApi(string $content, string $tempPath): string
|
||||
{
|
||||
$apiUrl = 'https://api.qrserver.com/v1/create-qr-code/?';
|
||||
$params = [
|
||||
'size' => '300x300',
|
||||
'data' => $content,
|
||||
'margin' => '10',
|
||||
];
|
||||
|
||||
$url = $apiUrl . http_build_query($params);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
$imageData = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200 || !$imageData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
file_put_contents($tempPath, $imageData);
|
||||
return $tempPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 OSS 上的二维码文件
|
||||
*
|
||||
* @param string $ossPath OSS路径
|
||||
* @return bool
|
||||
*/
|
||||
public function deleteQrCode(string $ossPath): bool
|
||||
{
|
||||
try {
|
||||
$ossService = new OssService();
|
||||
return $ossService->deleteFile($ossPath);
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -644,8 +644,6 @@ class StockOrdersService
|
||||
foreach ($vehList as $veh) {
|
||||
$veh['order_id'] = $order->id;
|
||||
$veh['unique_id'] = $this->generateVehicleUniqueId($veh);
|
||||
// 车辆附件:归一化数组(由模型 attachment=>'array' cast 自动序列化为 JSON 落库)
|
||||
$veh['attachment'] = is_array($veh['attachment'] ?? null) ? array_values($veh['attachment']) : [];
|
||||
|
||||
$item = StockOrderItems::create($veh);
|
||||
|
||||
@@ -669,7 +667,7 @@ class StockOrdersService
|
||||
*/
|
||||
private function generateVehicleUniqueId(array $veh): string
|
||||
{
|
||||
$keysToRemove = ['remark', 'modify_list', 'order_id', 'attachment'];
|
||||
$keysToRemove = ['remark', 'modify_list', 'order_id'];
|
||||
$array = array_diff_key($veh, array_flip($keysToRemove));
|
||||
$uniqueIdStr = json_encode($array);
|
||||
return md5($uniqueIdStr);
|
||||
@@ -884,18 +882,6 @@ class StockOrdersService
|
||||
$vehList[$v->unique_id]['remark'] = $v->remark;
|
||||
}
|
||||
}
|
||||
// 拼接附件(并集去重,按 url+文件名)
|
||||
$currAttach = is_array($v->attachment) ? $v->attachment : [];
|
||||
if (!empty($currAttach)) {
|
||||
$union = [];
|
||||
foreach (array_merge($vehList[$v->unique_id]['attachment'], $currAttach) as $a) {
|
||||
$key = ($a['url'] ?? '') . '|' . ($a['original_name'] ?? '');
|
||||
if (!isset($union[$key])) {
|
||||
$union[$key] = $a;
|
||||
}
|
||||
}
|
||||
$vehList[$v->unique_id]['attachment'] = array_values($union);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$index++;
|
||||
@@ -911,16 +897,8 @@ class StockOrdersService
|
||||
'mileage' => $v->mileage,
|
||||
'remark' => $v->remark,
|
||||
'vin' => $v->vin,
|
||||
'attachment' => is_array($v->attachment) ? $v->attachment : [],
|
||||
] + $moneyValues;
|
||||
}
|
||||
// 附件 url 响应期重签(历史存的是上传时的签名URL,会过期)
|
||||
foreach ($vehList as &$row) {
|
||||
if (!empty($row['attachment'])) {
|
||||
$row['attachment'] = \app\common\service\OssService::resignAttachmentUrls($row['attachment']);
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
return ['list' => $vehList];
|
||||
}
|
||||
|
||||
|
||||
@@ -91,11 +91,11 @@ class Feishu
|
||||
/**
|
||||
* 授权
|
||||
*/
|
||||
public function authorize($certificate, $url = 'authen/v1/authorize')
|
||||
public function authorize($certificate,$mobile, $url = 'authen/v1/authorize')
|
||||
{
|
||||
$arr = [
|
||||
'app_id' => $certificate['app_id'],
|
||||
'redirect_uri' => $certificate['r_url'],
|
||||
'redirect_uri' => $mobile == 1 ? $certificate['m_url'] : $certificate['r_url'],
|
||||
'scope' => $certificate['scope'],
|
||||
'state' => $certificate['state']
|
||||
];
|
||||
@@ -225,4 +225,101 @@ class Feishu
|
||||
// ];
|
||||
// return $this->requestResult($url);
|
||||
// }
|
||||
|
||||
/**
|
||||
* 获取 JSAPI 配置(签名)
|
||||
* @param string $url 当前页面完整 URL(不包含 # 后面的内容)
|
||||
* @return array
|
||||
*/
|
||||
public function getJsApiConfig($url)
|
||||
{
|
||||
// 1. 获取 tenant_access_token
|
||||
//$accessToken = $this->getTenantAccessToken();
|
||||
$accessToken = $this->tenantAccessToken();
|
||||
|
||||
// 2. 获取 jsapi_ticket
|
||||
$ticket = $this->getJsapiTicket($accessToken);
|
||||
|
||||
// 3. 生成签名参数
|
||||
$timestamp = (int) round(microtime(true) * 1000);
|
||||
$nonceStr = $this->generateNonceStr();
|
||||
|
||||
// 4. 生成签名
|
||||
$signature = $this->generateSignature($ticket, $nonceStr, $timestamp, $url);
|
||||
|
||||
return [
|
||||
'appId' => $this->appId,
|
||||
'timestamp' => $timestamp,
|
||||
'nonceStr' => $nonceStr,
|
||||
'signature' => $signature,
|
||||
'url' => $url
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 jsapi_ticket
|
||||
* @param string $accessToken
|
||||
* @return string
|
||||
*/
|
||||
private function getJsapiTicket($accessToken)
|
||||
{
|
||||
// 检查缓存
|
||||
$cachedTicket = Cache::get($this->abbr.'-feishu:jsapi_ticket');
|
||||
// $cachedTicket = $this->getCachedData('feishu:jsapi_ticket');
|
||||
if ($cachedTicket) {
|
||||
return $cachedTicket;
|
||||
}
|
||||
|
||||
// 使用 requestResult 方法发起请求,而不是 httpGet
|
||||
$this->options["headers"] = [
|
||||
"Authorization" => "Bearer " . $accessToken,
|
||||
"Content-Type" => "application/json; charset=utf-8"
|
||||
];
|
||||
|
||||
$result = $this->requestResult('jssdk/ticket/get', 'GET');
|
||||
|
||||
if (!$result) {
|
||||
throw new BusinessException('获取 jsapi_ticket 失败: ' . $this->error());
|
||||
}
|
||||
|
||||
$ticket = $result['data']['ticket'];
|
||||
$expire = $result['data']['expire_in'] - 300; // 提前5分钟过期
|
||||
|
||||
// 缓存 ticket
|
||||
Cache::set($this->abbr.'-feishu:jsapi_ticket', $ticket, $expire);
|
||||
// $this->setCachedData('feishu:jsapi_ticket', $ticket, $expire);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名
|
||||
* @param string $ticket
|
||||
* @param string $nonceStr
|
||||
* @param int $timestamp
|
||||
* @param string $url
|
||||
* @return string
|
||||
*/
|
||||
private function generateSignature($ticket, $nonceStr, $timestamp, $url)
|
||||
{
|
||||
// 拼接待签名字符串(注意顺序)
|
||||
$signStr = "jsapi_ticket={$ticket}&noncestr={$nonceStr}×tamp={$timestamp}&url={$url}";
|
||||
|
||||
return sha1($signStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机字符串
|
||||
* @param int $length
|
||||
* @return string
|
||||
*/
|
||||
private function generateNonceStr($length = 16)
|
||||
{
|
||||
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
$str = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$str .= substr($chars, mt_rand(0, strlen($chars) - 1), 1);
|
||||
}
|
||||
return $str;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\utils;
|
||||
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 询盘公平分配排序器(合塑谷歌三个导入脚本共用)
|
||||
*
|
||||
* 按"最近窗口期内实际分到的条数"升序排用户:分得少的优先、最久未分到的优先、
|
||||
* 再按传入名单顺序。统计池:crm_customer 中 channel_id=4(合塑谷歌)、type=2(导入)、
|
||||
* 未删除、窗口期内新建的记录。
|
||||
*
|
||||
* 场景自适应:
|
||||
* - 名单增删(离职移除/新人加入):每次执行按当次名单统计,无残留状态;
|
||||
* - 暂停一段时间再恢复:暂停期间窗口计数自然衰减,回归后适度优先拉平,
|
||||
* 不会饿死也不会被灌顶。
|
||||
*/
|
||||
class InquiryFairAssigner
|
||||
{
|
||||
/** 默认窗口期(天) */
|
||||
public const DEFAULT_WINDOW_DAYS = 10;
|
||||
|
||||
/**
|
||||
* 计算公平分配顺序
|
||||
*
|
||||
* @param array $userIds 本次参与分配的 user_id 列表
|
||||
* @param int $windowDays 统计窗口(天)
|
||||
* @return array{ordered: array, counts: array<int,int>, window_days: int}
|
||||
* ordered=排好序的 user_id 列表;counts=窗口内每人已有条数
|
||||
*/
|
||||
public static function fairOrder(array $userIds, int $windowDays = self::DEFAULT_WINDOW_DAYS): array
|
||||
{
|
||||
$since = time() - $windowDays * 86400;
|
||||
$counts = []; $lastIds = [];
|
||||
foreach (Db::table('crm_customer')
|
||||
->where('channel_id', 4)
|
||||
->where('type', 2)
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('user_id', $userIds)
|
||||
->where('created_at', '>=', $since)
|
||||
->groupBy('user_id')
|
||||
->selectRaw('user_id, COUNT(*) AS cnt, MAX(id) AS last_id')
|
||||
->get() as $r) {
|
||||
$uid = (int)$r->user_id;
|
||||
$counts[$uid] = (int)$r->cnt;
|
||||
$lastIds[$uid] = (int)$r->last_id;
|
||||
}
|
||||
|
||||
$pos = array_flip(array_map('intval', $userIds));
|
||||
$ordered = array_map('intval', $userIds);
|
||||
usort($ordered, function ($a, $b) use ($counts, $lastIds, $pos) {
|
||||
$ca = $counts[$a] ?? 0; $cb = $counts[$b] ?? 0;
|
||||
if ($ca !== $cb) {
|
||||
return $ca <=> $cb; // 窗口内分到少的优先
|
||||
}
|
||||
$la = $lastIds[$a] ?? 0; $lb = $lastIds[$b] ?? 0;
|
||||
if ($la !== $lb) {
|
||||
return $la <=> $lb; // 最久未分到的优先
|
||||
}
|
||||
return $pos[$a] <=> $pos[$b]; // 名单顺序(稳定)
|
||||
});
|
||||
|
||||
return ['ordered' => $ordered, 'counts' => $counts, 'window_days' => $windowDays];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\utils;
|
||||
|
||||
class SystemAuth
|
||||
{
|
||||
/**
|
||||
* 生成签名
|
||||
* @param string $userId 用户ID
|
||||
* @param int $timestamp Unix时间戳
|
||||
* @param string $secret 共享密钥
|
||||
* @return string
|
||||
*/
|
||||
public static function generateSignature(string $userId, int $timestamp, string $secret): string
|
||||
{
|
||||
$data = $userId . $timestamp;
|
||||
return hash_hmac('md5', $data, $secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证签名
|
||||
* @param string $userId 用户ID
|
||||
* @param int $timestamp Unix时间戳
|
||||
* @param string $receivedSignature 接收到的签名
|
||||
* @param string $secret 共享密钥
|
||||
* @return bool
|
||||
*/
|
||||
public static function verifySignature(string $userId, int $timestamp, string $receivedSignature, string $secret): bool
|
||||
{
|
||||
$expectedSignature = self::generateSignature($userId, $timestamp, $secret);
|
||||
return hash_equals($expectedSignature, $receivedSignature);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证时间戳是否在有效期内
|
||||
* @param int $timestamp Unix时间戳
|
||||
* @param int $maxSeconds 有效期(秒)
|
||||
* @return bool
|
||||
*/
|
||||
public static function verifyTimestamp(int $timestamp, int $maxSeconds = 600): bool
|
||||
{
|
||||
$now = time();
|
||||
$diff = abs($now - $timestamp);
|
||||
return $diff <= $maxSeconds;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
namespace app\common\utils\approval\traits;
|
||||
|
||||
use support\Db;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
trait ApprovalDiscussionTrait
|
||||
{
|
||||
/** 是否该实例参与者 */
|
||||
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;
|
||||
}
|
||||
|
||||
/** 发表讨论 */
|
||||
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,
|
||||
];
|
||||
}
|
||||
|
||||
/** 获取讨论列表 */
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
<?php
|
||||
namespace app\common\utils\approval\traits;
|
||||
|
||||
use support\Db;
|
||||
use support\Log;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
trait ApprovalEngineTrait
|
||||
{
|
||||
/** 启动审批流程 */
|
||||
public function start(string $flowType, string $bizType, string $bizId, array $formData, int $initiatorId): array
|
||||
{
|
||||
return Db::transaction(function () use ($flowType, $bizType, $bizId, $formData, $initiatorId) {
|
||||
$flow = Db::table('approval_flows')->where('type', $flowType)->where('is_active', 1)->first();
|
||||
if (!$flow) throw new BusinessException("审批流程未找到或已停用: {$flowType}");
|
||||
|
||||
$steps = json_decode($flow->steps, true);
|
||||
if (!is_array($steps) || empty($steps)) throw new BusinessException("流程步骤配置错误");
|
||||
|
||||
// 找首个有效步骤索引(考虑条件)
|
||||
$firstIndex = $this->getNextStepIndex($steps, $formData, -1);
|
||||
if ($firstIndex === null) throw new BusinessException("未找到可执行的审批步骤");
|
||||
$firstStep = $steps[$firstIndex];
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$deadlineAt = null;
|
||||
if (($flow->timeout_hours ?? 0) > 0) {
|
||||
$deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600);
|
||||
}
|
||||
|
||||
$instanceId = Db::table('approval_instances')->insertGetId([
|
||||
'flow_type' => $flowType,
|
||||
'biz_type' => $bizType,
|
||||
'biz_id' => $bizId,
|
||||
'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'initiator_id' => $initiatorId,
|
||||
'status' => 'processing',
|
||||
'current_step' => $firstIndex,
|
||||
'round' => 1,
|
||||
'deadline_at' => $deadlineAt,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$instance = (object)[
|
||||
'id' => $instanceId,
|
||||
'flow_type' => $flowType,
|
||||
'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'initiator_id' => $initiatorId,
|
||||
'current_step' => $firstIndex,
|
||||
'status' => 'processing',
|
||||
'flow_type' => $flowType,
|
||||
'biz_type' => $bizType,
|
||||
'biz_id' => $bizId,
|
||||
'round' => 1,
|
||||
];
|
||||
|
||||
// 解析首步审批人(支持 role_id 或 roles.name)
|
||||
$approverIds = $this->resolveApprovers($firstStep, $instance, true);
|
||||
foreach ($approverIds as $uid) {
|
||||
$this->insertApprovalRecord($instance, $firstIndex, $uid);
|
||||
}
|
||||
|
||||
$extra = [];
|
||||
$submitNote = $formData['submit_note'] ?? '';
|
||||
if (is_string($submitNote) && $submitNote !== '') {
|
||||
$extra['note'] = $submitNote;
|
||||
}
|
||||
$this->sendNotification($approverIds, $instance, 'pending', false, $extra);
|
||||
|
||||
// $this->notifyCCs($firstStep, $instance, 'pending');
|
||||
|
||||
$this->pushTodoChanged($approverIds);
|
||||
|
||||
return ['instance_id' => $instanceId];
|
||||
});
|
||||
}
|
||||
|
||||
/** 执行审批(幂等友好) */
|
||||
public function approve(int $instanceId, int $approverId, string $action, string $comment = '')
|
||||
{
|
||||
if (!in_array($action, ['approve','reject'], true)) {
|
||||
throw new BusinessException("无效的审批操作");
|
||||
}
|
||||
|
||||
return Db::transaction(function () use ($instanceId, $approverId, $action, $comment) {
|
||||
$instance = Db::table('approval_instances')->where('id', $instanceId)->lockForUpdate()->first();
|
||||
if (!$instance) throw new BusinessException("审批实例未找到");
|
||||
if ($instance->status !== 'processing') {
|
||||
return ['status' => 'noop', 'msg' => '实例未处理'];
|
||||
}
|
||||
|
||||
$flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first();
|
||||
if (!$flow) throw new BusinessException("审批流程定义未找到");
|
||||
|
||||
$steps = json_decode($flow->steps, true);
|
||||
$idx = (int)$instance->current_step;
|
||||
$round = (int)$instance->round;
|
||||
$step = $steps[$idx] ?? null;
|
||||
if (!$step) throw new BusinessException("当前步骤配置错误");
|
||||
|
||||
// 找到该用户在本步的 pending 记录(加锁)
|
||||
$pendingRecord = Db::table('approval_records')
|
||||
->where('instance_id', $instanceId)
|
||||
->where('round', $round)
|
||||
->where('approver_id', $approverId)
|
||||
->where('step_index', $idx)
|
||||
->where('status', 'pending')
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (!$pendingRecord) {
|
||||
// 已处理过?
|
||||
$anyRecord = Db::table('approval_records')
|
||||
->where('instance_id', $instanceId)
|
||||
->where('round', $round)
|
||||
->where('approver_id', $approverId)
|
||||
->where('step_index', $idx)
|
||||
->first();
|
||||
if ($anyRecord && $anyRecord->status !== 'pending') {
|
||||
return ['status' => 'noop', 'msg' => '已处理'];
|
||||
}
|
||||
// 步骤已推进
|
||||
if ((int)$instance->current_step !== $idx) {
|
||||
return ['status' => 'noop', 'msg' => '非当前审批'];
|
||||
}
|
||||
return ['status' => 'noop', 'msg' => '无待处理记录'];
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
// 1) 更新当前人的审批记录
|
||||
Db::table('approval_records')->where('id', $pendingRecord->id)->update([
|
||||
'action' => $action,
|
||||
'status' => ($action === 'approve') ? 'approved' : 'rejected',
|
||||
'comment' => $comment,
|
||||
'approved_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// 异步审批回调 9.23 add
|
||||
$approvalcallback = [
|
||||
'biz_id' => $instance->biz_id,
|
||||
'flow_type' => $instance->flow_type,
|
||||
'instance_id' => $instance->id,
|
||||
'initiator_id' => $instance->initiator_id,
|
||||
'status' => $action
|
||||
];
|
||||
\Webman\RedisQueue\Redis::send('approval-callback', $approvalcallback);
|
||||
|
||||
// 2) 聚合本步结论(any / all)
|
||||
$typeRaw = strtolower($step['type'] ?? 'all');
|
||||
$type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all';
|
||||
$outcome = $this->getStepOutcome($instanceId, $round, $idx, $type); // 'pending' | 'approve' | 'reject'
|
||||
|
||||
if ($outcome === 'pending') {
|
||||
$this->pushTodoChanged([$approverId]);
|
||||
return ['status' => 'step_pending', 'msg' => '审批待定'];
|
||||
}
|
||||
|
||||
$otherPendingUids = $this->getPendingApproverIds($instanceId, $round, $idx);
|
||||
|
||||
// 3) 有结论:关闭本步其余 pending
|
||||
$this->closeOtherPending($instanceId, $round, $idx, [$approverId]);
|
||||
|
||||
// 这些人 pending→skipped,待办数减少
|
||||
$this->pushTodoChanged($otherPendingUids);
|
||||
|
||||
// 4) 拒绝 => 结束
|
||||
if ($outcome === 'reject') {
|
||||
Db::table('approval_instances')->where('id', $instanceId)->update([
|
||||
'status' => 'rejected',
|
||||
'finished_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$this->onFlowFinalized($instance, 'rejected');
|
||||
$this->sendNotification([$instance->initiator_id], $instance, 'reject', false);
|
||||
$this->pushTodoChanged([$approverId]);
|
||||
return ['status' => 'completed', 'msg' => '拒绝'];
|
||||
}
|
||||
|
||||
// 5) 通过 => 进入下一步或结束
|
||||
$formData = json_decode($instance->form_data, true) ?: [];
|
||||
$nextIndex = $this->getNextStepIndex($steps, $formData, $idx);
|
||||
|
||||
if ($nextIndex === null) {
|
||||
$finalStatus = $this->normalizeFinalStatus((string)$instance->flow_type, 'approved');
|
||||
$notifyAction = ($finalStatus === 'voided') ? 'voided' : 'approve';
|
||||
|
||||
Db::table('approval_instances')->where('id', $instanceId)->update([
|
||||
'status' => $finalStatus,
|
||||
'finished_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$this->onFlowFinalized($instance, $finalStatus);
|
||||
|
||||
// 新增:在流程最终完成时才发本步骤的抄送
|
||||
if (!empty($step['cc'])) {
|
||||
$this->notifyCCs($step, $instance, $notifyAction);
|
||||
}
|
||||
|
||||
$this->sendNotification([$instance->initiator_id], $instance, $notifyAction, false);
|
||||
$this->pushTodoChanged([$approverId]);
|
||||
return ['status' => 'completed', 'msg' => ($finalStatus === 'voided' ? '已作废' : '同意')];
|
||||
}
|
||||
|
||||
// 乐观推进 current_step
|
||||
$affected = Db::table('approval_instances')
|
||||
->where('id', $instanceId)
|
||||
->where('current_step', $idx)
|
||||
->where('status', 'processing')
|
||||
->update(['current_step' => $nextIndex, 'updated_at' => $now]);
|
||||
if (!$affected) {
|
||||
return ['status' => 'noop', 'msg' => '并发进展'];
|
||||
}
|
||||
|
||||
// 生成下一步待办 + 通知
|
||||
$nextStep = $steps[$nextIndex];
|
||||
$nextApprovers = $this->resolveApprovers($nextStep, $instance, true);
|
||||
foreach ($nextApprovers as $uid) {
|
||||
$this->insertApprovalRecord($instance, $nextIndex, $uid);
|
||||
}
|
||||
$this->sendNotification($nextApprovers, $instance, 'pending', false);
|
||||
// $this->notifyCCs($nextStep, $instance, 'pending');
|
||||
$this->pushTodoChanged(array_merge([$approverId], $nextApprovers));
|
||||
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,
|
||||
]);
|
||||
|
||||
$this->onFlowFinalized($instance, 'revoked');
|
||||
|
||||
$pendingUids = Db::table('approval_records')
|
||||
->where('instance_id', $instanceId)
|
||||
->where('status', 'pending')
|
||||
->pluck('approver_id')->toArray();
|
||||
|
||||
Db::table('approval_records')
|
||||
->where('instance_id', $instanceId)
|
||||
->where('status', 'pending')
|
||||
->update(['status' => 'skipped', 'approved_at' => $now, 'updated_at' => $now]);
|
||||
|
||||
$this->sendNotification([$initiatorId], $instance, 'revoked', false);
|
||||
$this->pushTodoChanged($pendingUids);
|
||||
return ['status' => 'completed', 'result' => 'revoked'];
|
||||
});
|
||||
}
|
||||
|
||||
/** 定时任务:处理超时(实例级) */
|
||||
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';
|
||||
$final = $this->normalizeFinalStatus((string)$instance->flow_type, $final);
|
||||
$finalAction = ($final === 'voided') ? 'voided' : (($final === 'approved') ? 'approve' : 'reject');
|
||||
|
||||
Db::table('approval_instances')->where('id', $instance->id)->update([
|
||||
'status' => $final,
|
||||
'finished_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->onFlowFinalized((object)$instance, $final);
|
||||
|
||||
$pendingUids = Db::table('approval_records')
|
||||
->where('instance_id', $instance->id)
|
||||
->where('round', (int)$instance->round)
|
||||
->where('step_index', $instance->current_step)
|
||||
->where('status', 'pending')
|
||||
->pluck('approver_id')->toArray();
|
||||
|
||||
Db::table('approval_records')
|
||||
->where('instance_id', $instance->id)
|
||||
->where('round', (int)$instance->round)
|
||||
->where('step_index', $instance->current_step)
|
||||
->where('status', 'pending')
|
||||
->update(['status' => 'timeout', 'approved_at' => $now, 'updated_at' => $now]);
|
||||
|
||||
$this->notifyInitiator($instance, $finalAction);
|
||||
|
||||
$this->pushTodoChanged($pendingUids);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 重提交(仅对 status=rejected 的实例;支持申诉说明) */
|
||||
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);
|
||||
}
|
||||
|
||||
$oldPendingUids = Db::table('approval_records')
|
||||
->where('instance_id', $instanceId)
|
||||
->where('status', 'pending')
|
||||
->pluck('approver_id')->toArray();
|
||||
|
||||
// 清理可能遗留的 pending
|
||||
Db::table('approval_records')
|
||||
->where('instance_id', $instanceId)
|
||||
->where('status', 'pending')
|
||||
->update(['status'=>'skipped','approved_at'=>$now,'updated_at'=>$now]);
|
||||
|
||||
// 轮次 +1
|
||||
$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');
|
||||
$this->pushTodoChanged(array_merge($oldPendingUids, $approvers));
|
||||
|
||||
return [
|
||||
'status' => 'resubmitted',
|
||||
'instance_id' => (int)$instanceId,
|
||||
'current_step' => $firstIndex,
|
||||
'step_name' => $firstStep['name'] ?? ('Step '.$firstIndex),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** 新增:发起“作废审批” */
|
||||
public function startNullify(
|
||||
string $voidFlowType,
|
||||
string $bizType,
|
||||
string $bizId,
|
||||
array $formData,
|
||||
int $initiatorId
|
||||
): array {
|
||||
return Db::transaction(function () use ($voidFlowType, $bizType, $bizId, $formData, $initiatorId) {
|
||||
// 1) 流程定义校验
|
||||
$flow = Db::table('approval_flows')->where('type', $voidFlowType)->where('is_active', 1)->first();
|
||||
if (!$flow) throw new BusinessException("作废流程未找到或已停用: {$voidFlowType}");
|
||||
$steps = json_decode($flow->steps, true);
|
||||
if (!is_array($steps) || empty($steps)) throw new BusinessException("作废流程步骤配置错误");
|
||||
|
||||
// 2) 必须存在“已通过”的最新有效实例(非作废流程)
|
||||
$latestApproved = Db::table('approval_instances')
|
||||
->where('biz_type', $bizType)
|
||||
->where('biz_id', $bizId)
|
||||
->where('flow_type', '!=', $voidFlowType)
|
||||
->where('status', 'approved')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if (!$latestApproved) {
|
||||
throw new BusinessException('仅已通过的单据可发起作废');
|
||||
}
|
||||
|
||||
// 3) 不允许并行的作废流程
|
||||
$voidProcessing = Db::table('approval_instances')
|
||||
->where('flow_type', $voidFlowType)
|
||||
->where('biz_id', $bizId)
|
||||
->where('status', 'processing')
|
||||
->exists();
|
||||
if ($voidProcessing) {
|
||||
throw new BusinessException('已存在进行中的作废审批');
|
||||
}
|
||||
|
||||
// A. 基于历史“作废流程已通过”的实例拦截
|
||||
$voidApproved = Db::table('approval_instances')
|
||||
->where('flow_type', $voidFlowType)
|
||||
->where('biz_id', $bizId)
|
||||
->whereIn('status', ['approved','voided'])
|
||||
->exists();
|
||||
if ($voidApproved) {
|
||||
throw new BusinessException('该单据已作废,无需重复作废');
|
||||
}
|
||||
|
||||
// B.(可选)直接查业务表状态拦截(若你用 approval_status=4 表示已作废)
|
||||
/*
|
||||
$orderVoided = \app\common\model\StockOrders::where('id', $bizId)
|
||||
->where('approval_status', 4)
|
||||
->exists();
|
||||
if ($orderVoided) {
|
||||
throw new BusinessException('该单据已作废,无需重复作废');
|
||||
}
|
||||
*/
|
||||
|
||||
// 4) 计算首步
|
||||
// 注:作废流程可以有独立条件;若不需要条件,直接第一步
|
||||
$formData = array_merge([
|
||||
'_op' => 'nullify',
|
||||
'_nullify_reason' => $formData['nullify_reason'] ?? ($formData['reason'] ?? ''),
|
||||
'_nullify_of_instance_id' => (int)$latestApproved->id,
|
||||
], $formData);
|
||||
|
||||
$firstIndex = $this->getNextStepIndex($steps, $formData, -1);
|
||||
if ($firstIndex === null) throw new BusinessException("作废流程未命中任何审批步骤");
|
||||
$firstStep = $steps[$firstIndex];
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$deadlineAt = null;
|
||||
if (($flow->timeout_hours ?? 0) > 0) {
|
||||
$deadlineAt = date('Y-m-d H:i:s', time() + $flow->timeout_hours * 3600);
|
||||
}
|
||||
|
||||
// 5) 新建作废实例(round 固定从 1 开始)
|
||||
$instanceId = Db::table('approval_instances')->insertGetId([
|
||||
'flow_type' => $voidFlowType,
|
||||
'biz_type' => $bizType,
|
||||
'biz_id' => $bizId,
|
||||
'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'initiator_id' => $initiatorId,
|
||||
'status' => 'processing',
|
||||
'current_step' => $firstIndex,
|
||||
'round' => 1, //
|
||||
'deadline_at' => $deadlineAt,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$instance = (object)[
|
||||
'id' => $instanceId,
|
||||
'flow_type' => $voidFlowType,
|
||||
'biz_type' => $bizType,
|
||||
'biz_id' => $bizId,
|
||||
'form_data' => json_encode($formData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'initiator_id' => $initiatorId,
|
||||
'current_step' => $firstIndex,
|
||||
'status' => 'processing',
|
||||
'round' => 1, //
|
||||
];
|
||||
|
||||
// 6) 生成首步待办 + 通知(带上作废原因)
|
||||
$approverIds = $this->resolveApprovers($firstStep, $instance, true);
|
||||
foreach ($approverIds as $uid) {
|
||||
$this->insertApprovalRecord($instance, $firstIndex, $uid);
|
||||
}
|
||||
$extra = [];
|
||||
if (!empty($formData['_nullify_reason'])) {
|
||||
$extra['note'] = '【作废原因】'.$formData['_nullify_reason'];
|
||||
}
|
||||
$this->sendNotification($approverIds, $instance, 'pending', false, $extra);
|
||||
$this->notifyCCs($firstStep, $instance, 'pending');
|
||||
$this->pushTodoChanged($approverIds);
|
||||
return ['instance_id' => $instanceId];
|
||||
});
|
||||
}
|
||||
|
||||
// 流程完结后的业务回调(approved|rejected|revoked|timeout?)
|
||||
private function onFlowFinalized(object $instance, string $final): void
|
||||
{
|
||||
try {
|
||||
switch ($instance->flow_type) {
|
||||
case 'stock_orders': {
|
||||
$order = \app\common\model\StockOrders::find($instance->biz_id);
|
||||
if (!$order) {
|
||||
throw new BusinessException('订单不存在');
|
||||
}
|
||||
if ($final === 'approved') {
|
||||
$order->approval_status = 2;
|
||||
$order->save();
|
||||
} elseif ($final === 'rejected') {
|
||||
$order->approval_status = 3;
|
||||
$order->save();
|
||||
} elseif ($final === 'revoked') {
|
||||
$order->approval_status = 0;
|
||||
$order->save();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'stock_orders_nullify':
|
||||
if ($final === 'approved') {
|
||||
$final = 'voided';
|
||||
Db::table('approval_instances')->where('id', $instance->id)->update([
|
||||
'status' => 'voided',
|
||||
]);
|
||||
}
|
||||
if (in_array($final, ['voided','approved'], true)) {
|
||||
$order = \app\common\model\StockOrders::find($instance->biz_id);
|
||||
if ($order && $order->approval_status !== 4) {
|
||||
$order->approval_status = 4;
|
||||
$order->save();
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// 非作废流程,无需处理
|
||||
break;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('onFlowFinalized error: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 是否作废流程
|
||||
private function isNullifyFlow(string $flowType): bool
|
||||
{
|
||||
$t = strtolower(trim($flowType));
|
||||
return str_ends_with($t, '_nullify') || in_array($t, [
|
||||
// 所有“作废”类流程的类型
|
||||
'stock_orders_nullify',
|
||||
], true);
|
||||
}
|
||||
|
||||
// 归一化最终状态
|
||||
private function normalizeFinalStatus(string $flowType, string $final): string
|
||||
{
|
||||
if ($this->isNullifyFlow($flowType) && $final === 'approved') {
|
||||
return 'voided';
|
||||
}
|
||||
return $final;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
namespace app\common\utils\approval\traits;
|
||||
|
||||
use support\Db;
|
||||
use support\Log;
|
||||
|
||||
trait ApprovalNotificationTrait
|
||||
{
|
||||
/** 发送通知 */
|
||||
private function sendNotification(array $userIds, object $instance, string $action, bool $isCC, array $extra = []): void
|
||||
{
|
||||
$map = [
|
||||
'pending' => '待审批',
|
||||
'approve' => '已通过',
|
||||
'reject' => '已拒绝',
|
||||
'revoked' => '已撤回',
|
||||
'voided' => '已作废',
|
||||
];
|
||||
$prefix = $isCC ? '【抄送】' : '【审批】';
|
||||
$text = $map[$action] ?? $action;
|
||||
|
||||
// 业务标题/内容
|
||||
$approvalType = array_flip(\app\common\service\SysDictService::pluckKeyValueByCode('approval_type'));
|
||||
|
||||
$flowTxt = $approvalType[$instance->flow_type] ?? (string)$instance->flow_type;
|
||||
$title = $prefix.'审批通知';
|
||||
$content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务标识 {$instance->biz_id}";
|
||||
|
||||
switch ($instance->flow_type) {
|
||||
case 'stock_orders':
|
||||
// 备货单
|
||||
$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':
|
||||
// 备货单作废
|
||||
$stockOrder = \app\common\model\StockOrders::find($instance->biz_id);
|
||||
if ($stockOrder) {
|
||||
$title = $prefix."备货单作废:{$stockOrder->order_no}";
|
||||
$flowTxt = $approvalType[$instance->flow_type] ?? '备货单作废';
|
||||
$content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务单号 {$stockOrder->order_no}";
|
||||
// 把作废原因附带展示更清晰(也可依赖 $extra['note'])
|
||||
$fd = json_decode($instance->form_data ?? '{}', true) ?: [];
|
||||
if (!empty($fd['_nullify_reason'])) {
|
||||
$content .= ';原因:'.(function_exists('mb_strimwidth') ? mb_strimwidth($fd['_nullify_reason'], 0, 120, '...', 'UTF-8') : $fd['_nullify_reason']);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'purchase_orders':
|
||||
// 采购单
|
||||
$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;
|
||||
case 'inv_requisition':
|
||||
// 备货单
|
||||
$invRequisition = \app\common\model\InvRequisition::find($instance->biz_id);
|
||||
if ($invRequisition) {
|
||||
$title = $prefix."物品借用:{$invRequisition->order_no}";
|
||||
$flowTxt = $approvalType[$instance->flow_type] ?? $instance->flow_type;
|
||||
$content = "{$prefix}你有一条审批 {$text}:类型 {$flowTxt},业务单号 {$invRequisition->order_no}";
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/** WebSocket:把一组用户的“待办数量”推送给前端(旧版) */
|
||||
private function pushTodoChanged22(array $userIds): void
|
||||
{
|
||||
$userIds = array_values(array_unique(array_map('intval', $userIds)));
|
||||
if (empty($userIds)) return;
|
||||
|
||||
// 统计这些用户当前的待办数量(以 approval_records.status = 'pending' 为准)
|
||||
$rows = Db::table('approval_records')
|
||||
->whereIn('approver_id', $userIds)
|
||||
->where('status', 'pending')
|
||||
->groupBy('approver_id')
|
||||
->select('approver_id', Db::raw('COUNT(*) as cnt'))
|
||||
->get();
|
||||
|
||||
// 默认都置 0,再填充真实值
|
||||
$payload = array_fill_keys($userIds, 0);
|
||||
foreach ($rows as $r) {
|
||||
$payload[(int)$r->approver_id] = (int)$r->cnt;
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($payload as $uid => $cnt) {
|
||||
$data = [
|
||||
'event' => 'approval.todo.counts',
|
||||
'user_id' => $uid,
|
||||
'num' => $cnt,
|
||||
];
|
||||
\Webman\RedisQueue\Redis::send('ws-msg', $data);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ws todo push error: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** WebSocket:把一组用户的“待办数量”推送给前端(统一口径) */
|
||||
private function pushTodoChanged(array $userIds): void
|
||||
{
|
||||
$userIds = array_values(array_unique(array_map('intval', $userIds)));
|
||||
if (empty($userIds)) return;
|
||||
|
||||
// 只统计:实例进行中 + 当前轮次 + 当前步骤 的 pending
|
||||
$rows = Db::table('approval_records as ar')
|
||||
->join('approval_instances as ai', 'ai.id', '=', 'ar.instance_id')
|
||||
->whereIn('ar.approver_id', $userIds)
|
||||
->where('ar.status', 'pending')
|
||||
->where('ai.status', 'processing')
|
||||
->whereRaw('ar.round = ai.round')
|
||||
->whereRaw('ar.step_index = ai.current_step')
|
||||
->groupBy('ar.approver_id')
|
||||
->select('ar.approver_id', Db::raw('COUNT(*) as cnt'))
|
||||
->get();
|
||||
|
||||
// 默认置 0,再填充真实值
|
||||
$payload = array_fill_keys($userIds, 0);
|
||||
foreach ($rows as $r) {
|
||||
$payload[(int)$r->approver_id] = (int)$r->cnt;
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($payload as $uid => $cnt) {
|
||||
(new \app\common\utils\WsEvents())->todoCounts($uid, $cnt);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ws todo push error: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
<?php
|
||||
namespace app\common\utils\approval\traits;
|
||||
|
||||
use support\Db;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
trait ApprovalQueryTrait
|
||||
{
|
||||
/** 流程图数据 */
|
||||
public function getFlowDiagramData(int $instanceId): array
|
||||
{
|
||||
$instance = Db::table('approval_instances')->find($instanceId);
|
||||
if (!$instance) return [];
|
||||
|
||||
$flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first();
|
||||
if (!$flow) return [];
|
||||
|
||||
$steps = json_decode($flow->steps, true) ?: [];
|
||||
$records = Db::table('approval_records')
|
||||
->where('instance_id', $instanceId)
|
||||
->where('round', (int)$instance->round)
|
||||
->orderBy('step_index')->orderByRaw('COALESCE(approved_at, created_at) asc')
|
||||
->get()
|
||||
->groupBy('step_index');
|
||||
|
||||
$formData = json_decode($instance->form_data, true) ?: [];
|
||||
$data = [];
|
||||
foreach ($steps as $i => $step) {
|
||||
$actions = ($records->get($i) ?? collect())->toArray();
|
||||
|
||||
if (!empty($actions)) {
|
||||
$cntApproved = 0; $cntRejected = 0; $cntPending = 0;
|
||||
foreach ($actions as $a) {
|
||||
if ($a->status === 'approved') $cntApproved++;
|
||||
elseif ($a->status === 'rejected') $cntRejected++;
|
||||
elseif ($a->status === 'pending') $cntPending++;
|
||||
// skipped/timeout 不阻断完结显示
|
||||
}
|
||||
if ($cntRejected > 0) $status = 'rejected';
|
||||
elseif ($cntPending > 0) $status = 'processing';
|
||||
elseif ($cntApproved > 0) $status = 'approved';
|
||||
else $status = 'approved'; // 全是 skipped/timeout 也视为已过
|
||||
} else {
|
||||
if ($i < (int)$instance->current_step) {
|
||||
if (isset($step['condition']) && !$this->evaluateCondition($step['condition'], $formData)) {
|
||||
$status = 'skipped';
|
||||
} else {
|
||||
$status = 'approved';
|
||||
}
|
||||
} elseif ($i === (int)$instance->current_step && $instance->status === 'processing') {
|
||||
$status = 'processing';
|
||||
} else {
|
||||
$status = 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'index' => $i,
|
||||
'name' => $step['name'] ?? ('Step '.$i),
|
||||
'type' => $step['type'] ?? 'all',
|
||||
'status' => $status,
|
||||
'actions' => $actions,
|
||||
];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** 查询某个审批实例的审批记录 */
|
||||
public function 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,
|
||||
];
|
||||
}
|
||||
|
||||
/** 简单时间线 */
|
||||
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获取“最新进展”的极简状态 */
|
||||
public function getBriefStatusByBiz(string $bizId, ?string $bizType = null, ?string $userNameColumn = null): array
|
||||
{
|
||||
// 1) 最新实例
|
||||
$q = Db::table('approval_instances')->where('biz_id', $bizId);
|
||||
if ($bizType !== null && $bizType !== '') {
|
||||
$q->where('biz_type', $bizType);
|
||||
}
|
||||
$instance = $q->orderByDesc('id')->first();
|
||||
if (!$instance) {
|
||||
// throw new BusinessException('未找到对应的审批实例');
|
||||
return [];
|
||||
}
|
||||
|
||||
$base = [
|
||||
'instance_id' => (int)$instance->id,
|
||||
'flow_type' => $instance->flow_type,
|
||||
'biz_type' => $instance->biz_type,
|
||||
'biz_id' => $instance->biz_id,
|
||||
'status' => $instance->status,
|
||||
'finished_at' => $instance->finished_at,
|
||||
'updated_at' => $instance->updated_at,
|
||||
];
|
||||
|
||||
// 2) 已完结:直接返回
|
||||
if ($instance->status !== 'processing') {
|
||||
return $base;
|
||||
}
|
||||
|
||||
// 3) 进行中:取当前步骤 + 当前 pending 审批者
|
||||
$flow = Db::table('approval_flows')->where('type', $instance->flow_type)->first();
|
||||
if (!$flow) {
|
||||
// 容错:没有流程定义
|
||||
return $base;
|
||||
}
|
||||
$steps = json_decode($flow->steps, true) ?: [];
|
||||
$idx = (int)$instance->current_step;
|
||||
$round = (int)$instance->round;
|
||||
$meta = $steps[$idx] ?? [];
|
||||
|
||||
$typeRaw = strtolower($meta['type'] ?? 'all');
|
||||
$type = in_array($typeRaw, ['any','all']) ? $typeRaw : 'all';
|
||||
|
||||
// 当前步的所有记录
|
||||
$recsQ = Db::table('approval_records')->where('instance_id', $instance->id)->where('step_index', $idx);
|
||||
|
||||
// 待处理人(只列出 pending)
|
||||
if ($userNameColumn) {
|
||||
$pendingRows = Db::table('approval_records as ar')
|
||||
->leftJoin('user as u', 'ar.approver_id', '=', 'u.id')
|
||||
->where('ar.instance_id', $instance->id)
|
||||
->where('ar.round', $round)
|
||||
->where('ar.step_index', $idx)
|
||||
->where('ar.status', 'pending')
|
||||
->select('ar.approver_id', Db::raw("u.`{$userNameColumn}` as approver_name"))
|
||||
->get()
|
||||
->toArray();
|
||||
} else {
|
||||
$pendingRows = Db::table('approval_records')
|
||||
->where('instance_id', $instance->id)
|
||||
->where('round', $round)
|
||||
->where('step_index', $idx)
|
||||
->where('status', 'pending')
|
||||
->select('approver_id')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$pendingApprovers = [];
|
||||
foreach ($pendingRows as $r) {
|
||||
$row = ['approver_id' => (int)$r->approver_id];
|
||||
if ($userNameColumn && property_exists($r, 'approver_name')) {
|
||||
$row['approver_name'] = $r->approver_name;
|
||||
}
|
||||
$pendingApprovers[] = $row;
|
||||
}
|
||||
|
||||
// 当前步的聚合状态(进行中通常返回 processing;若全被超时任务置为 timeout,则返回 timeout)
|
||||
// 先快速统计是否纯 timeout
|
||||
$stats = Db::table('approval_records')
|
||||
->where('instance_id', $instance->id)
|
||||
->where('round', $round)
|
||||
->where('step_index', $idx)
|
||||
->selectRaw("SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) as c_pending")
|
||||
->selectRaw("SUM(CASE WHEN status='approved' THEN 1 ELSE 0 END) as c_approved")
|
||||
->selectRaw("SUM(CASE WHEN status='rejected' THEN 1 ELSE 0 END) as c_rejected")
|
||||
->selectRaw("SUM(CASE WHEN status='timeout' THEN 1 ELSE 0 END) as c_timeout")
|
||||
->first();
|
||||
|
||||
$stepStatus = 'processing';
|
||||
if ($stats && (int)$stats->c_timeout > 0 && (int)$stats->c_pending === 0 && (int)$stats->c_approved === 0 && (int)$stats->c_rejected === 0) {
|
||||
$stepStatus = 'timeout';
|
||||
}
|
||||
|
||||
$base['step'] = [
|
||||
'index' => $idx,
|
||||
'name' => $meta['name'] ?? ('Step '.$idx),
|
||||
'type' => $type, // any|all
|
||||
'status' => $stepStatus, // processing|timeout
|
||||
'pending_approvers' => $pendingApprovers, // 当前正在等待的审批者
|
||||
];
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
/** 登录/首屏初始化:获取当前用户的待办总数 */
|
||||
public function getInitTodoCount(int $userId): int
|
||||
{
|
||||
$userId = (int)$userId;
|
||||
if ($userId <= 0) {
|
||||
throw new BusinessException('无效的用户ID');
|
||||
}
|
||||
|
||||
$row = Db::table('approval_records as ar')
|
||||
->join('approval_instances as ai', 'ai.id', '=', 'ar.instance_id')
|
||||
->where('ar.approver_id', $userId)
|
||||
->where('ar.status', 'pending')
|
||||
->where('ai.status', 'processing')
|
||||
// 只统计实例“当前轮次、当前步骤”的待办,避免历史残留干扰
|
||||
->whereRaw('ar.round = ai.round')
|
||||
->whereRaw('ar.step_index = ai.current_step')
|
||||
->selectRaw('COUNT(*) as c')
|
||||
->first();
|
||||
|
||||
return (int)($row->c ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
namespace app\common\utils\approval\traits;
|
||||
|
||||
use support\Db;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
trait ApprovalResolverTrait
|
||||
{
|
||||
/** 解析审批人(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;
|
||||
|
||||
case 'group_members': {
|
||||
// $gid = (int)($cfg['group_id'] ?? 0);
|
||||
$gid = (int)($cfg['value'] ?? 0);
|
||||
if ($gid <= 0) throw new BusinessException('group_members 需要 group_id');
|
||||
$ids = $this->getGroupMemberIds($gid); // A 方案
|
||||
break;
|
||||
}
|
||||
|
||||
case 'scenario_leader_of_initiator': {
|
||||
$formData = json_decode($instance->form_data ?? '{}', true) ?: [];
|
||||
// $scenario = $cfg['scenario'] ?? ($formData[$cfg['scenario_field']] ?? null);
|
||||
$scenario = $val ?? ($formData[$cfg['scenario_field']] ?? null);
|
||||
if (!$scenario) throw new BusinessException('缺少业务场景(scenario 或 scenario_field)');
|
||||
|
||||
$mustBelong = array_key_exists('must_belong', $cfg) ? (bool)$cfg['must_belong'] : true;
|
||||
$preferPrimary = array_key_exists('prefer_primary', $cfg) ? (bool)$cfg['prefer_primary'] : true;
|
||||
|
||||
$ids = $this->getLeadersByScenarioForInitiator(
|
||||
(int)$instance->initiator_id,
|
||||
(string)$scenario,
|
||||
$mustBelong,
|
||||
$preferPrimary
|
||||
); // B 方案(按场景)
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new BusinessException("不支持的审批人类型: {$typ}");
|
||||
}
|
||||
$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;
|
||||
case 'group_members': {
|
||||
$gid = (int)($val ?? 0);
|
||||
if ($gid <= 0) throw new BusinessException('group_members 需要 group_id');
|
||||
$uids = $this->getGroupMemberIds($gid); // A 方案
|
||||
break;
|
||||
}
|
||||
case 'scenario_leader_of_initiator': {
|
||||
$formData = json_decode($instance->form_data ?? '{}', true) ?: [];
|
||||
$scenario = $val ?? ($formData[$cc['scenario_field']] ?? null);
|
||||
if (!$scenario) throw new BusinessException('缺少业务场景(scenario 或 scenario_field)');
|
||||
|
||||
$mustBelong = array_key_exists('must_belong', $cc) ? (bool)$cc['must_belong'] : true;
|
||||
$preferPrimary = array_key_exists('prefer_primary', $cc) ? (bool)$cc['prefer_primary'] : true;
|
||||
|
||||
$uids = $this->getLeadersByScenarioForInitiator(
|
||||
(int)$instance->initiator_id,
|
||||
(string)$scenario,
|
||||
$mustBelong,
|
||||
$preferPrimary
|
||||
); // B 方案(按场景)
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
$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}"),
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取下一个有效步骤索引 */
|
||||
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;
|
||||
}
|
||||
|
||||
/** 聚合本步骤结论 */
|
||||
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 = $approved = $rejected = $timeout = $skipped = 0;
|
||||
foreach ($records as $r) {
|
||||
switch ($r->status) {
|
||||
case 'pending': $pending++; break;
|
||||
case 'approved': $approved++; break;
|
||||
case 'rejected': $rejected++; break;
|
||||
case 'timeout': $timeout++; break;
|
||||
case 'skipped': $skipped++; break;
|
||||
}
|
||||
}
|
||||
|
||||
$type = strtolower($type);
|
||||
if ($type === 'any') {
|
||||
if ($rejected > 0) return 'reject';
|
||||
if ($approved > 0) return 'approve';
|
||||
// 全为 skipped/timeout 且无 pending/rejected ⇒ 通过
|
||||
if ($pending === 0 && $rejected === 0 && $approved === 0) return 'approve';
|
||||
return 'pending';
|
||||
}
|
||||
if ($rejected > 0) return 'reject';
|
||||
if ($pending > 0) return 'pending';
|
||||
return 'approve';
|
||||
}
|
||||
|
||||
/** 关闭本步其余待办为 skipped */
|
||||
private function closeOtherPending(int $instanceId, int $round, int $stepIndex, array $keepApproverIds = []): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$q = Db::table('approval_records')
|
||||
->where('instance_id', $instanceId)
|
||||
->where('round', $round) //
|
||||
->where('step_index', $stepIndex)
|
||||
->where('status', 'pending');
|
||||
|
||||
if (!empty($keepApproverIds)) $q->whereNotIn('approver_id', $keepApproverIds);
|
||||
|
||||
$q->update(['status'=>'skipped','approved_at'=>$now,'updated_at'=>$now]);
|
||||
}
|
||||
|
||||
/** 创建待办(去重) */
|
||||
private function insertApprovalRecord(object $instance, int $stepIndex, int $approverId): void
|
||||
{
|
||||
$round = (int)($instance->round ?? 1);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$exists = Db::table('approval_records')
|
||||
->where('instance_id', (int)$instance->id)
|
||||
->where('round', $round) // 限当前轮
|
||||
->where('step_index', $stepIndex)
|
||||
->where('approver_id', $approverId)
|
||||
->where('status', 'pending')
|
||||
->exists();
|
||||
|
||||
if ($exists) return;
|
||||
|
||||
Db::table('approval_records')->insert([
|
||||
'instance_id' => (int)$instance->id,
|
||||
'approver_id' => $approverId,
|
||||
'step_index' => $stepIndex,
|
||||
'round' => $round, //
|
||||
'action' => null,
|
||||
'status' => 'pending',
|
||||
'comment' => null,
|
||||
'approved_at' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 取“某实例、某轮次、某步骤”里当前 pending 的审批人 */
|
||||
private function getPendingApproverIds(int $instanceId, int $round, int $stepIndex): array
|
||||
{
|
||||
$ids = Db::table('approval_records')
|
||||
->where('instance_id', $instanceId)
|
||||
->where('round', $round)
|
||||
->where('step_index', $stepIndex)
|
||||
->where('status', 'pending')
|
||||
->pluck('approver_id')->toArray();
|
||||
|
||||
return array_values(array_unique(array_map('intval', $ids)));
|
||||
}
|
||||
|
||||
/** 从角色取用户ID */
|
||||
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;
|
||||
}
|
||||
|
||||
/** A) 取组内全部成员 */
|
||||
private function getGroupMemberIds(int $groupId): array
|
||||
{
|
||||
$rows = Db::table('user_group_members')
|
||||
->where('group_id', $groupId)
|
||||
->where('status', 1)
|
||||
->pluck('user_id')->toArray();
|
||||
|
||||
return array_values(array_unique(array_map('intval', $rows)));
|
||||
}
|
||||
|
||||
/** B) 按“场景”在发起人所在的组里找负责人 */
|
||||
private function getLeadersByScenarioForInitiator(
|
||||
int $initiatorId,
|
||||
string $scenarioCode,
|
||||
bool $mustBelong = true,
|
||||
bool $preferPrimary = true
|
||||
): array {
|
||||
// 找出“该场景下发起人所在的所有组”
|
||||
$groups = Db::table('user_group as g')
|
||||
->join('user_group_members as m', 'm.group_id', '=', 'g.id')
|
||||
->where('g.scenario_code', $scenarioCode)
|
||||
->where('g.status', 1)
|
||||
->where('m.user_id', $initiatorId)
|
||||
->where('m.status', 1)
|
||||
->orderByDesc('m.is_primary') // 主归属优先
|
||||
->orderByDesc('g.sort') // 其次按组排序
|
||||
->select('g.id', 'm.is_primary')
|
||||
->get()->toArray();
|
||||
|
||||
if (empty($groups)) {
|
||||
if ($mustBelong) {
|
||||
throw new BusinessException('发起人在该业务场景下未加入任何分组');
|
||||
}
|
||||
// 不强制属于:允许只按“该场景下任一组”的负责人(不推荐)
|
||||
$groups = Db::table('user_group as g')
|
||||
->where('g.scenario_code', $scenarioCode)
|
||||
->where('g.status', 1)
|
||||
->orderByDesc('g.sort')
|
||||
->select('g.id', Db::raw('0 as is_primary'))
|
||||
->get()->toArray();
|
||||
if (empty($groups)) {
|
||||
throw new BusinessException('该业务场景下未配置任何分组');
|
||||
}
|
||||
}
|
||||
|
||||
// 选择一个“目标组”
|
||||
$targetGroupId = null;
|
||||
if ($preferPrimary) {
|
||||
foreach ($groups as $g) {
|
||||
if ((int)$g->is_primary === 1) { $targetGroupId = (int)$g->id; break; }
|
||||
}
|
||||
}
|
||||
if (!$targetGroupId) {
|
||||
$targetGroupId = (int)$groups[0]->id; // 用排序第一的组
|
||||
}
|
||||
|
||||
// 取负责人(可支持多负责人)
|
||||
$leaders = Db::table('user_group_members')
|
||||
->where('group_id', $targetGroupId)
|
||||
->where('is_leader', 1)
|
||||
->where('status', 1)
|
||||
->pluck('user_id')->toArray();
|
||||
|
||||
$ids = array_values(array_unique(array_map('intval', $leaders)));
|
||||
if (empty($ids)) throw new BusinessException('目标分组未配置负责人');
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
public function StetApprovers(array $step, object $instance, bool $avoidSelf = true){
|
||||
return$this->resolveApprovers($step, $instance, $avoidSelf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
namespace app\common\utils\approval\traits;
|
||||
|
||||
use support\Db;
|
||||
use support\exception\BusinessException;
|
||||
|
||||
trait ApprovalTransferTrait
|
||||
{
|
||||
/** 查看某个实例的转交流水(按时间升序) */
|
||||
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);
|
||||
}
|
||||
|
||||
/** 转交当前待办给他人 */
|
||||
public function transferApproval(
|
||||
int $instanceId,
|
||||
int $stepIndex,
|
||||
int $fromUserId,
|
||||
int $toUserId,
|
||||
string $reason = '',
|
||||
?string $userNameColumn = null
|
||||
): 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,
|
||||
]);
|
||||
|
||||
$this->pushTodoChanged([$fromUserId, $toUserId]);
|
||||
return ['status' => 'ok', 'record_id' => $effectiveRecordId];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\utils\email;
|
||||
|
||||
class InquiryDigestMail
|
||||
{
|
||||
/**
|
||||
* 构建汇总邮件 HTML
|
||||
*
|
||||
* @param string $userName 收件人姓名
|
||||
* @param array $inquiries 询盘数组,每项含 code/contact/email/phone/country_cn/channel/remark
|
||||
* @param string $date 日期串 Y-m-d
|
||||
* @return string HTML
|
||||
*/
|
||||
public static function build(string $userName, array $inquiries, string $date): string
|
||||
{
|
||||
$count = count($inquiries);
|
||||
$rows = '';
|
||||
$idx = 1;
|
||||
foreach ($inquiries as $q) {
|
||||
$remark = mb_substr((string)($q['remark'] ?? ''), 0, 100);
|
||||
$td = '<td style="border:1px solid #eee;padding:6px;">';
|
||||
$rows .= '<tr>'
|
||||
. $td . $idx++ . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['code'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['contact'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['email'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['phone'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['country_cn'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['channel'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars($remark) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
$thStyle = 'style="border:1px solid #eee;padding:6px;background:#f0f0f0;"';
|
||||
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"></head>'
|
||||
. '<body style="margin:0;padding:0;background:#f6f7f9;font-family:Arial,Helvetica,sans-serif;">'
|
||||
. '<div style="max-width:960px;margin:0 auto;padding:20px;">'
|
||||
. '<h2 style="color:#111;">询盘分配汇总</h2>'
|
||||
. '<p>收件人:<strong>' . htmlspecialchars($userName) . '</strong> 日期:' . $date
|
||||
. ' 共 <strong>' . $count . '</strong> 条</p>'
|
||||
. '<table style="border-collapse:collapse;width:100%;font-size:13px;color:#333;">'
|
||||
. '<thead><tr>'
|
||||
. '<th ' . $thStyle . '>#</th>'
|
||||
. '<th ' . $thStyle . '>编号</th>'
|
||||
. '<th ' . $thStyle . '>联系人</th>'
|
||||
. '<th ' . $thStyle . '>邮箱</th>'
|
||||
. '<th ' . $thStyle . '>电话</th>'
|
||||
. '<th ' . $thStyle . '>国家</th>'
|
||||
. '<th ' . $thStyle . '>渠道</th>'
|
||||
. '<th ' . $thStyle . '>意向/备注</th>'
|
||||
. '</tr></thead><tbody>'
|
||||
. $rows
|
||||
. '</tbody></table></div></body></html>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class InvItemValidate extends Validate
|
||||
{
|
||||
// 规则
|
||||
protected $rule = [
|
||||
];
|
||||
|
||||
// 提示
|
||||
protected $message = [
|
||||
];
|
||||
|
||||
// 场景
|
||||
protected $scene = [
|
||||
'update' => [
|
||||
],
|
||||
|
||||
'qrCode' => [
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class InvRequisitionApproveValidate extends Validate
|
||||
{
|
||||
// 规则
|
||||
protected $rule = [
|
||||
|
||||
];
|
||||
|
||||
// 提示
|
||||
protected $message = [
|
||||
|
||||
];
|
||||
|
||||
// 场景
|
||||
protected $scene = [
|
||||
'store' => [
|
||||
|
||||
],
|
||||
|
||||
'update' => [
|
||||
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class InvRequisitionAttachmentValidate extends Validate
|
||||
{
|
||||
// 规则
|
||||
protected $rule = [
|
||||
|
||||
];
|
||||
|
||||
// 提示
|
||||
protected $message = [
|
||||
|
||||
];
|
||||
|
||||
// 场景
|
||||
protected $scene = [
|
||||
'store' => [
|
||||
|
||||
],
|
||||
|
||||
'update' => [
|
||||
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class InvRequisitionItemReturnAttachmentValidate extends Validate
|
||||
{
|
||||
// 规则
|
||||
protected $rule = [
|
||||
|
||||
];
|
||||
|
||||
// 提示
|
||||
protected $message = [
|
||||
|
||||
];
|
||||
|
||||
// 场景
|
||||
protected $scene = [
|
||||
'store' => [
|
||||
|
||||
],
|
||||
|
||||
'update' => [
|
||||
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class InvRequisitionItemReturnValidate extends Validate
|
||||
{
|
||||
// 规则
|
||||
protected $rule = [
|
||||
|
||||
];
|
||||
|
||||
// 提示
|
||||
protected $message = [
|
||||
|
||||
];
|
||||
|
||||
// 场景
|
||||
protected $scene = [
|
||||
'store' => [
|
||||
|
||||
],
|
||||
|
||||
'update' => [
|
||||
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class InvRequisitionItemValidate extends Validate
|
||||
{
|
||||
// 规则
|
||||
protected $rule = [
|
||||
|
||||
];
|
||||
|
||||
// 提示
|
||||
protected $message = [
|
||||
|
||||
];
|
||||
|
||||
// 场景
|
||||
protected $scene = [
|
||||
'store' => [
|
||||
|
||||
],
|
||||
|
||||
'update' => [
|
||||
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class InvRequisitionReturnValidate extends Validate
|
||||
{
|
||||
// 规则
|
||||
protected $rule = [
|
||||
|
||||
];
|
||||
|
||||
// 提示
|
||||
protected $message = [
|
||||
|
||||
];
|
||||
|
||||
// 场景
|
||||
protected $scene = [
|
||||
'store' => [
|
||||
|
||||
],
|
||||
|
||||
'update' => [
|
||||
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\validate;
|
||||
|
||||
use think\Validate;
|
||||
|
||||
class InvRequisitionValidate extends Validate
|
||||
{
|
||||
// 规则
|
||||
protected $rule = [
|
||||
'applicant_id' => 'number',
|
||||
'borrow_reason' => 'require|max:255',
|
||||
'use_start_date' => 'require|date',
|
||||
'use_end_date' => 'date',
|
||||
'items' => 'require|array',
|
||||
'items.*.item_id' => 'require|number',
|
||||
'items.*.request_qty' => 'require|number|gt:0',
|
||||
// 扫码领取规则
|
||||
'item_id' => 'require|integer|gt:0',
|
||||
'qty' => 'require|integer|gt:0',
|
||||
// 管理员代领规则
|
||||
'target_user_id' => 'integer|gt:0',
|
||||
'target_user_ids' => 'array',
|
||||
'target_user_ids.*' => 'integer|gt:0',
|
||||
];
|
||||
|
||||
// 提示
|
||||
protected $message = [
|
||||
'applicant_id.number' => '申请人格式错误',
|
||||
'borrow_reason.require' => '领用事由不能为空',
|
||||
'borrow_reason.max' => '领用事由最多不能超过255个字符',
|
||||
'use_start_date.require' => '使用开始日期不能为空',
|
||||
'use_start_date.date' => '使用开始日期格式错误',
|
||||
'use_end_date.date' => '预计归还日期格式错误',
|
||||
'items.require' => '申请物品明细不能为空',
|
||||
'items.array' => '申请物品明细格式错误',
|
||||
'items.*.item_id.require' => '物品ID不能为空',
|
||||
'items.*.item_id.number' => '物品ID格式错误',
|
||||
'items.*.request_qty.require' => '申请数量不能为空',
|
||||
'items.*.request_qty.number' => '申请数量格式错误',
|
||||
'items.*.request_qty.gt' => '申请数量必须大于0',
|
||||
'item_id.require' => '物品ID不能为空',
|
||||
'item_id.integer' => '物品ID必须为整数',
|
||||
'item_id.gt' => '物品ID必须大于0',
|
||||
'qty.require' => '领取数量不能为空',
|
||||
'qty.integer' => '领取数量必须为整数',
|
||||
'qty.gt' => '领取数量必须大于0',
|
||||
'target_user_id.require' => '代领用户不能为空',
|
||||
];
|
||||
|
||||
// 场景
|
||||
protected $scene = [
|
||||
'store' => [
|
||||
'applicant_id',
|
||||
'borrow_reason',
|
||||
'use_start_date',
|
||||
'use_end_date',
|
||||
'items'
|
||||
],
|
||||
|
||||
'update' => [
|
||||
'applicant_id',
|
||||
'borrow_reason',
|
||||
'use_start_date',
|
||||
'use_end_date',
|
||||
'items'
|
||||
],
|
||||
|
||||
'apply' => [
|
||||
'applicant_id',
|
||||
'borrow_reason',
|
||||
'use_start_date',
|
||||
'use_end_date',
|
||||
'items'
|
||||
],
|
||||
|
||||
'qrClaim' => [
|
||||
'item_id',
|
||||
'qty',
|
||||
],
|
||||
|
||||
'proxyClaim' => [
|
||||
'item_id',
|
||||
'qty',
|
||||
'target_user_id',
|
||||
'target_user_ids',
|
||||
'borrow_reason',
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -139,10 +139,6 @@ class StockOrdersController extends BaseController
|
||||
];
|
||||
}
|
||||
unset($item['options']);
|
||||
// 附件 url 响应期重签(历史签名URL会过期)
|
||||
if (!empty($item['attachment']) && is_array($item['attachment'])) {
|
||||
$item['attachment'] = \app\common\service\OssService::resignAttachmentUrls($item['attachment']);
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ use app\common\model\Corp;
|
||||
use app\common\model\CustomerAudit;
|
||||
use app\common\model\InquiryLog;
|
||||
use app\common\model\User;
|
||||
use app\common\service\OssService;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\crm\logic\CustomerLogic;
|
||||
use app\crm\model\Channel;
|
||||
@@ -378,36 +377,9 @@ class CustomerService
|
||||
}
|
||||
}
|
||||
|
||||
// WhatsApp导入截图:desc_collect 里的 OSS 对象键 -> 带签名 <img>(前端 v-html 渲染)
|
||||
$customer['desc_collect'] = self::signScreenshotImages((string)($customer['desc_collect'] ?? ''));
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 desc_collect 中 crm_whatsapp 截图的 OSS 对象键替换为带签名的 <img> 标签。
|
||||
* 键按原文匹配签名(历史记录为双斜杠键,对象即存于该键,不可归一)。
|
||||
*/
|
||||
private static function signScreenshotImages(string $desc): string
|
||||
{
|
||||
if ($desc === '' || strpos($desc, 'crm_whatsapp/') === false) {
|
||||
return $desc;
|
||||
}
|
||||
return preg_replace_callback(
|
||||
'/([A-Za-z0-9_\-]+(?:\/[A-Za-z0-9_\-]+)*\/+crm_whatsapp\/[A-Za-z0-9_\-\.]+\.(?:png|jpe?g|webp))/',
|
||||
function ($m) {
|
||||
try {
|
||||
$url = (new OssService())->generatePrivateUrl($m[1], 7 * 86400);
|
||||
return '<img src="' . htmlspecialchars($url, ENT_QUOTES) . '" '
|
||||
. 'style="max-width:100%;height:auto;display:block;margin:4px 0" />';
|
||||
} catch (\Throwable $e) {
|
||||
return $m[0];
|
||||
}
|
||||
},
|
||||
$desc
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public function detail($params)
|
||||
{
|
||||
|
||||
@@ -21,25 +21,6 @@ class StockOrdersService extends \app\common\service\StockOrdersService
|
||||
switch ($scene) {
|
||||
case 'index':
|
||||
|
||||
// ====== view_scope:仅 user_id=73 生效,最高优先级,覆盖下方业务id默认值与角色范围逻辑 ======
|
||||
$viewScope = $p['view_scope'] ?? '';
|
||||
if ($request->user->uid == 73 && in_array($viewScope, ['all', 'self'], true)) {
|
||||
if ($viewScope === 'self') {
|
||||
$config['where']['salesperson_id'] = $request->user->uid; // 只看自己(含自己草稿)
|
||||
} else { // 'all':自己和别人的,排除草稿(approval_status=0)
|
||||
$config['where']['approval_status'] = ['>=', 1];
|
||||
}
|
||||
$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';
|
||||
break; // 最高优先级:跳过下方业务id默认值与角色范围逻辑
|
||||
}
|
||||
|
||||
// ====== ====== 筛选 ====== ======
|
||||
// 业务id
|
||||
if (isset($p['salesperson_id']) && $p['salesperson_id']) {
|
||||
@@ -61,7 +42,7 @@ class StockOrdersService extends \app\common\service\StockOrdersService
|
||||
|
||||
$user_role_id = UserRoles::where('user_id', $request->user->uid)->pluck('role_id')->toArray();
|
||||
//$roles_id = array_intersect([1,6], $request->user->role_ids);
|
||||
$roles_id = array_intersect([1,4,5,6], $user_role_id);
|
||||
$roles_id = array_intersect([1,5,6], $user_role_id);
|
||||
if (count($roles_id) != 0){
|
||||
// if(in_array(4, $request->user->role_ids)){ //业务主管
|
||||
// $report = User::where('report_to',$request->user->uid)->pluck('id')->toArray();
|
||||
@@ -88,10 +69,6 @@ class StockOrdersService extends \app\common\service\StockOrdersService
|
||||
$config['orWhere'][] = ['approval_status','>=',1];
|
||||
$config['orWhere'][] = ['salesperson_id','=',$request->user->uid];
|
||||
unset($config['where']['salesperson_id']);
|
||||
} elseif (in_array(4,$roles_id)) { //业务主管:自己+直属下属(report_to指向他,一级,含草稿)
|
||||
$report = User::where('report_to',$request->user->uid)->pluck('id')->toArray();
|
||||
$report[] = $request->user->uid;
|
||||
$config['where']['salesperson_id'] = ['in',$report];
|
||||
}else{
|
||||
$config['where']['salesperson_id'] = $request->user->uid;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ class Util
|
||||
if($length <= 6){
|
||||
return $str;
|
||||
}
|
||||
return mb_substr($str, 0, 3) . '****' . mb_substr($str, -4);
|
||||
return substr($str, 0, 3) . '****' . substr($str, -4);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -463,36 +463,9 @@ class CustomerService
|
||||
$customer['enter_user_name'] = empty($customer['enter_user_id']) ? '' : User::where('id',$customer['enter_user_id'])->value('name');
|
||||
$customer['user_name'] = empty($customer['user_id']) ? '公海' : User::where('id',$customer['user_id'])->value('name');
|
||||
|
||||
// WhatsApp导入截图:desc_collect 里的 OSS 对象键 -> 带签名 <img>(前端 v-html 渲染)
|
||||
$customer['desc_collect'] = self::signScreenshotImages((string)($customer['desc_collect'] ?? ''));
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 desc_collect 中 crm_whatsapp 截图的 OSS 对象键替换为带签名的 <img> 标签。
|
||||
* 键按原文匹配签名(历史记录为双斜杠键,对象即存于该键,不可归一)。
|
||||
*/
|
||||
private static function signScreenshotImages(string $desc): string
|
||||
{
|
||||
if ($desc === '' || strpos($desc, 'crm_whatsapp/') === false) {
|
||||
return $desc;
|
||||
}
|
||||
return preg_replace_callback(
|
||||
'/([A-Za-z0-9_\-]+(?:\/[A-Za-z0-9_\-]+)*\/+crm_whatsapp\/[A-Za-z0-9_\-\.]+\.(?:png|jpe?g|webp))/',
|
||||
function ($m) {
|
||||
try {
|
||||
$url = (new OssService())->generatePrivateUrl($m[1], 7 * 86400);
|
||||
return '<img src="' . htmlspecialchars($url, ENT_QUOTES) . '" '
|
||||
. 'style="max-width:100%;height:auto;display:block;margin:4px 0" />';
|
||||
} catch (\Throwable $e) {
|
||||
return $m[0];
|
||||
}
|
||||
},
|
||||
$desc
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public function detail($params)
|
||||
{
|
||||
|
||||
@@ -114,11 +114,6 @@ class FbFileImportService
|
||||
$item['phone'] = $this->normalizePhone($raw);
|
||||
break;
|
||||
|
||||
// ✅ 姓名:去掉粘连的 URL,并截断到下游 inquiry_log.contacts 列长度(255)
|
||||
case 'full_name':
|
||||
$item['full_name'] = $this->normalizeFullName($raw);
|
||||
break;
|
||||
|
||||
default:
|
||||
if (is_string($raw)) $raw = trim($raw);
|
||||
$item[$field] = $raw;
|
||||
@@ -197,27 +192,6 @@ class FbFileImportService
|
||||
return $s;
|
||||
}
|
||||
|
||||
/**
|
||||
* 姓名清洗:
|
||||
* - 去掉粘连的 URL(如 "Saif Ullahhttps://..." → "Saif Ullah")
|
||||
* - 纯链接记录清洗后为空字符串
|
||||
* - 截断到 255(下游 inquiry_log.contacts 列长度,避免同步入库时超长报错)
|
||||
*/
|
||||
protected function normalizeFullName($raw): string
|
||||
{
|
||||
if ($raw === null) return '';
|
||||
|
||||
$s = trim((string)$raw);
|
||||
if ($s === '') return '';
|
||||
|
||||
// 去掉 URL(连同前面可能的空白/不可见字符)
|
||||
$s = preg_replace('#\s*https?://\S+#i', '', $s);
|
||||
$s = trim($s);
|
||||
|
||||
// 截断到下游 contacts 列长度兜底
|
||||
return mb_substr($s, 0, 255);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 规范化 is_organic 字段为 0/1
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace app\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
class ApiKeyAuth implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$apiKey = $request->header('Fdesk-Api-Key', '');
|
||||
$validKey = getenv('PUBLIC_API_KEY');
|
||||
|
||||
if ($validKey === false || $apiKey === '' || !hash_equals($validKey, $apiKey)) {
|
||||
$body = [
|
||||
'status' => 'fail',
|
||||
'code' => 401,
|
||||
'message' => '无效的 API Key',
|
||||
'data' => [],
|
||||
];
|
||||
return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
return $handler($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
namespace app\mobile\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\model\Corp;
|
||||
use app\common\model\User;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\InvItemService;
|
||||
use support\Request;
|
||||
|
||||
class InvItemController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InvItemService();
|
||||
}
|
||||
|
||||
public function preset(Request $request){
|
||||
try {
|
||||
$res = $this->service->getList();
|
||||
$result = [];
|
||||
foreach ($res as $value) {
|
||||
$value['company_name'] = Corp::where('id', $value['company_id'])->value('name');
|
||||
$value['manage_user_name'] = User::where('id', $value['manage_user_id'])->value('name');
|
||||
$result[] = $value;
|
||||
}
|
||||
return $this->success($result, '获取物品列表');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
protected function indexFormat($data)
|
||||
{
|
||||
if (isset($data['created_at']))
|
||||
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
if (isset($data['updated_at']))
|
||||
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
|
||||
if (isset($data['company_id']))
|
||||
$data['company_name'] = Corp::where('id', $data['company_id'])->value('name');
|
||||
if (isset($data['manage_user_id']))
|
||||
$data['manage_user_name'] = User::where('id', $data['manage_user_id'])->value('name');
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
namespace app\mobile\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\model\Corp;
|
||||
use app\common\model\InvItem;
|
||||
use app\common\model\InvRequisitionApprove;
|
||||
use app\common\model\InvRequisitionApproveAttachment;
|
||||
use app\common\model\InvRequisitionAttachment;
|
||||
use app\common\model\InvRequisitionItem;
|
||||
use app\common\service\OssService;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\InvRequisitionService;
|
||||
use support\Request;
|
||||
|
||||
class InvRequisitionController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InvRequisitionService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交物品申请
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function apply(Request $request)
|
||||
{
|
||||
try {
|
||||
// 验证申请数据
|
||||
$data = $this->validate($this->service->getValidator(), $request->post(), 'apply');
|
||||
|
||||
// 处理申请逻辑
|
||||
$result = $this->service->serviceStore($request, $data, 'apply');
|
||||
|
||||
return $this->success($result, '申请提交成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return mixed 返回分页数据及总数
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
try {
|
||||
$param = $request->all();
|
||||
$applicant_id = $request->get('applicant_id');
|
||||
$raw = $this->service->serviceList($request);
|
||||
$uid = $request->user->uid;
|
||||
$data = $this->formatindex($raw,$applicant_id,$uid,$param);
|
||||
// $data = $this->transformData($raw, method_exists($this, 'indexFormat')
|
||||
// ? [$this, 'indexFormat']
|
||||
// : null);
|
||||
return $this->success($data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function formatindex($p,$applicant_id,$uid,$param){
|
||||
$records = $p->getCollection()
|
||||
->map(fn($item) => $this->indexFormat($item, $applicant_id,$uid))
|
||||
->toArray();
|
||||
|
||||
if(isset($param['phase']) && $param['phase'] == 'pending'){
|
||||
foreach ($records as $key => $record) {
|
||||
if($record['action']==1 || $record['action']==2)
|
||||
unset($records[$key]);
|
||||
}
|
||||
$records = array_values($records);
|
||||
}
|
||||
|
||||
return [
|
||||
'records' => $records,
|
||||
'total' => $p->total(),
|
||||
'current' => $p->currentPage(),
|
||||
'size' => $p->perPage(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
protected function indexFormat($data, $applicant_id, $uid)
|
||||
{
|
||||
if(isset($data['id']) && is_object($data)){
|
||||
$data = $data->toArray();
|
||||
}
|
||||
if (isset($data['created_at'])){
|
||||
$data['created_at'] = is_int($data['created_at'])?$data['created_at']:strtotime($data['created_at']);
|
||||
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
}
|
||||
if (isset($data['updated_at'])){
|
||||
$data['updated_at'] = is_int($data['updated_at'])?$data['updated_at']:strtotime($data['updated_at']);
|
||||
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
|
||||
}
|
||||
if (isset($data['id'])){
|
||||
$data['item'] = InvRequisitionItem::where('requisition_id', $data['id'])->get();
|
||||
}
|
||||
|
||||
if($applicant_id != 'self' && isset($data['id'])){ //不是查询自己数据时查询是否已审核
|
||||
$approve = InvRequisitionApprove::where('requisition_id', $data['id'])
|
||||
->where('approver_id', $uid)
|
||||
->first();
|
||||
$data['action'] = $approve['action']??3;//查询人审核状态:0待审核 1通过 2驳回 3非审核人
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
protected function showFormat($data)
|
||||
{
|
||||
$oss = new OssService();
|
||||
if (isset($data['id'])){
|
||||
$data['approve'] = InvRequisitionApprove::where('requisition_id', $data['id'])->get()->toArray();
|
||||
foreach ($data['approve'] as $key => $item){
|
||||
$attachment = InvRequisitionApproveAttachment::where('approve_id', $item['id'])->get();
|
||||
foreach ($attachment as $k => $v){
|
||||
$attachment[$k] = $v->toArray();
|
||||
$attachment[$k]['file_path_pull'] = $oss->generatePrivateUrl($v['file_path']);
|
||||
}
|
||||
$data['approve'][$key]['attachment'] = $attachment;
|
||||
}
|
||||
$approve_id = !empty($data['approve'])?array_pop($data['approve'])['id']:null;
|
||||
$data['approve_id'] = $approve_id;
|
||||
}
|
||||
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['id'])){
|
||||
$data['item'] = InvRequisitionItem::where('requisition_id', $data['id'])->get();
|
||||
$data['attachment'] = InvRequisitionAttachment::where('requisition_id', $data['id'])->get();
|
||||
foreach ($data['attachment'] as $key => $value) {
|
||||
$data['attachment'][$key]['file_path_pull'] = $oss->generatePrivateUrl($value['file_path']);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫码领取物品
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function qrClaim(Request $request)
|
||||
{
|
||||
try {
|
||||
$data = $this->service->qrClaim($request, $request->post());
|
||||
return $this->success($data, '领取成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的扫码/代领记录
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function myQrClaims(Request $request)
|
||||
{
|
||||
try {
|
||||
// 只查询当前用户的扫码记录,通过设置 applicant_id=self 实现
|
||||
$request->setGet('applicant_id','self');
|
||||
$data = $this->service->qrClaimsList($request);
|
||||
return $this->success($data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
namespace app\mobile\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\model\Corp;
|
||||
use app\common\model\InvItem;
|
||||
use app\common\model\InvRequisitionItem;
|
||||
use app\common\model\InvRequisitionItemReturn;
|
||||
use app\common\model\InvRequisitionItemReturnAttachment;
|
||||
use app\common\service\OssService;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\InvRequisitionItemReturnService;
|
||||
use support\Request;
|
||||
|
||||
class InvRequisitionItemReturnController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InvRequisitionItemReturnService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return mixed 返回分页数据及总数
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
try {
|
||||
//$request->setGet('$request');
|
||||
$raw = $this->service->serviceList($request);
|
||||
$data = $this->transformData($raw, method_exists($this, 'indexFormat')
|
||||
? [$this, 'indexFormat']
|
||||
: null);
|
||||
return $this->success($data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
protected function indexFormat($data)
|
||||
{
|
||||
if(isset($data['id'])){
|
||||
$data['return_item'] = InvRequisitionItemReturn::where('return_id', $data['id'])->get();
|
||||
foreach ($data['return_item'] as $key => $value) {
|
||||
$data['return_item'][$key]['item'] = InvItem::where('id', $value['item_id'])->get();
|
||||
}
|
||||
}
|
||||
if (isset($data['return_at']))
|
||||
$data['return_at'] = date('Y-m-d H:i:s', $data['return_at']);
|
||||
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 请求对象
|
||||
* @param int|string $id 资源 ID
|
||||
* @return mixed 返回资源详情数据
|
||||
*/
|
||||
public function show(Request $request)
|
||||
{
|
||||
try {
|
||||
$raw = $this->service->serviceShow($request);
|
||||
$data = $this->transformData($raw, method_exists($this, 'showFormat')
|
||||
? [$this, 'showFormat']
|
||||
: null);
|
||||
return $this->success($data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
protected function showFormat($data)
|
||||
{
|
||||
foreach ($data['item_return'] as $key => $item){
|
||||
$data['item_return'][$key]['requisition_item'] = InvRequisitionItem::where('id', $item['requisition_item_id'])->first();
|
||||
}
|
||||
if (isset($data['return_at']))
|
||||
$data['return_at'] = date('Y-m-d H:i:s', $data['return_at']);
|
||||
if (isset($data['id'])){
|
||||
$data['return_attachment'] = InvRequisitionItemReturnAttachment::where('return_id', $data['id'])->get();
|
||||
$ossservice = new OssService();
|
||||
foreach ($data['return_attachment'] as $item){
|
||||
$item['file_path_pull'] = $ossservice->generatePrivateUrl($item['file_path']);
|
||||
|
||||
}
|
||||
|
||||
//归还审核附件
|
||||
$data['return_approve_attachment'] = InvRequisitionItemReturnAttachment::where('return_id', $data['id'])->get();
|
||||
foreach ($data['return_approve_attachment'] as $item){
|
||||
$item['file_path_pull'] = $ossservice->generatePrivateUrl($item['file_path']);
|
||||
}
|
||||
}
|
||||
/*if (isset($data['created_at']))
|
||||
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
|
||||
if (isset($data['updated_at']))
|
||||
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);*/
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 更新
|
||||
*
|
||||
* @param Request $request 请求对象
|
||||
* @return mixed 返回更新成功的数据或信息
|
||||
*/
|
||||
public function update(Request $request)
|
||||
{
|
||||
try {
|
||||
$data = $this->validate($this->service->getValidator(), $request->post(), 'update');
|
||||
$res = $this->service->serviceUpdate($request, $data);
|
||||
return $this->success([], '更新成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 物品归还确认
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function returnItemConfirm(Request $request)
|
||||
{
|
||||
try {
|
||||
// 验证申请数据
|
||||
$data = $this->validate($this->service->getValidator(), $request->post(), 'returnConfirm');
|
||||
// 处理申请逻辑
|
||||
$result = $this->service->serviceReturnItemConfirm($request, $data, 'returnConfirm');
|
||||
|
||||
return $this->success($result, '申请提交成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 物品归还确认
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function returnConfirm(Request $request)
|
||||
{
|
||||
try {
|
||||
// 验证申请数据
|
||||
$data = $this->validate($this->service->getValidator(), $request->post(), 'returnConfirm');
|
||||
// 处理申请逻辑
|
||||
$result = $this->service->serviceReturnConfirm($request, $data, 'returnConfirm');
|
||||
|
||||
return $this->success($result, '申请提交成功');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
namespace app\mobile\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\model\InvRequisitionItem;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\common\service\InvRequisitionReturnService;
|
||||
|
||||
class InvRequisitionReturnController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new InvRequisitionReturnService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
protected function indexFormat($data)
|
||||
{
|
||||
if (isset($data['id'])) {
|
||||
$data['item'] = InvRequisitionItem::where('requisition_id', $data['id'])->get();
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -12,16 +12,6 @@ class CheckWeb
|
||||
|
||||
public function onWorkerStart()
|
||||
{
|
||||
// if (getenv('ENVIRONMENT') === 'master') {
|
||||
// new Crontab('0 */10 * * * *', function(){
|
||||
// $result = $this->tocurl('https://magicbridgeauto.com');
|
||||
// if (!preg_match('/magicbridgeauto/', $result['body'])) {
|
||||
// flg("【magicbridgeauto】疑似被篡改");
|
||||
// }
|
||||
// return true;
|
||||
// });
|
||||
// }
|
||||
|
||||
// 每秒执行:队列二手车数据处理
|
||||
new Crontab('* * * * * *', function(){
|
||||
$service = new \app\common\utils\crawl\service\UsedCarCrawl();
|
||||
|
||||
+2
-1
@@ -49,7 +49,8 @@
|
||||
"mpdf/mpdf": "^8.2",
|
||||
"jaeger/querylist": "^4.4",
|
||||
"zoujingli/ip2region": "^3.0",
|
||||
"webman/log": "^2.1"
|
||||
"webman/log": "^2.1",
|
||||
"endroid/qr-code": "^6.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-event": "For better performance. "
|
||||
|
||||
Generated
+154
-1
@@ -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": "bf987c3cd5510fc0637d6b64d460dde1",
|
||||
"content-hash": "28a80d57e5dbc1da167b66780b6dfd00",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aliyuncs/oss-sdk-php",
|
||||
@@ -51,6 +51,55 @@
|
||||
},
|
||||
"time": "2024-10-28T10:41:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
"version": "v3.0.3",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://mirrors.cloud.tencent.com/repository/composer/bacon/bacon-qr-code/v3.0.3/bacon-bacon-qr-code-v3.0.3.zip",
|
||||
"reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dasprid/enum": "^1.0.3",
|
||||
"ext-iconv": "*",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phly/keep-a-changelog": "^2.12",
|
||||
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
|
||||
"spatie/phpunit-snapshot-assertions": "^5.1.5",
|
||||
"spatie/pixelmatch-php": "^1.2.0",
|
||||
"squizlabs/php_codesniffer": "^3.9"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-imagick": "to generate QR code images"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BaconQrCode\\": "src/"
|
||||
}
|
||||
},
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "BaconQrCode is a QR code generator for PHP.",
|
||||
"homepage": "https://github.com/Bacon/BaconQrCode",
|
||||
"support": {
|
||||
"issues": "https://github.com/Bacon/BaconQrCode/issues",
|
||||
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.3"
|
||||
},
|
||||
"time": "2025-11-19T17:15:36+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.14.1",
|
||||
@@ -259,6 +308,50 @@
|
||||
],
|
||||
"time": "2024-11-12T16:29:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dasprid/enum",
|
||||
"version": "1.0.7",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://mirrors.cloud.tencent.com/repository/composer/dasprid/enum/1.0.7/dasprid-enum-1.0.7.zip",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1 <9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"DASPRiD\\Enum\\": "src/"
|
||||
}
|
||||
},
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP 7.1 enum implementation",
|
||||
"keywords": [
|
||||
"enum",
|
||||
"map"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/DASPRiD/Enum/issues",
|
||||
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
|
||||
},
|
||||
"time": "2025-09-16T12:23:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/inflector",
|
||||
"version": "2.1.0",
|
||||
@@ -349,6 +442,66 @@
|
||||
],
|
||||
"time": "2025-08-10T19:31:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "endroid/qr-code",
|
||||
"version": "6.0.9",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://mirrors.cloud.tencent.com/repository/composer/endroid/qr-code/6.0.9/endroid-qr-code-6.0.9.zip",
|
||||
"reference": "21e888e8597440b2205e2e5c484b6c8e556bcd1a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"php": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"endroid/quality": "dev-main",
|
||||
"ext-gd": "*",
|
||||
"khanamiryan/qrcode-detector-decoder": "^2.0.2",
|
||||
"setasign/fpdf": "^1.8.2"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "Enables you to write PNG images",
|
||||
"khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator",
|
||||
"roave/security-advisories": "Makes sure package versions with known security issues are not installed",
|
||||
"setasign/fpdf": "Enables you to use the PDF writer"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "6.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Endroid\\QrCode\\": "src/"
|
||||
}
|
||||
},
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jeroen van den Enden",
|
||||
"email": "info@endroid.nl"
|
||||
}
|
||||
],
|
||||
"description": "Endroid QR Code",
|
||||
"homepage": "https://github.com/endroid/qr-code",
|
||||
"keywords": [
|
||||
"code",
|
||||
"endroid",
|
||||
"php",
|
||||
"qr",
|
||||
"qrcode"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/endroid/qr-code/issues",
|
||||
"source": "https://github.com/endroid/qr-code/tree/6.0.9"
|
||||
},
|
||||
"time": "2025-07-13T19:59:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "firebase/php-jwt",
|
||||
"version": "v6.11.1",
|
||||
|
||||
+81
-7
@@ -29,6 +29,11 @@ use Webman\Route;
|
||||
Route::any('/c', [app\controller\IndexController::class, 'createResource']);
|
||||
Route::any('/perm', [app\controller\IndexController::class, 'perm']);
|
||||
|
||||
// 系统间认证接口
|
||||
Route::group('/api/system', function () {
|
||||
Route::any('/auth', [app\common\controller\SystemController::class, 'auth']);
|
||||
});
|
||||
|
||||
// 飞书
|
||||
Route::any('/feishu/event', [app\common\controller\NotifyController::class, 'feishuEvent']);
|
||||
// 飞书回调
|
||||
@@ -51,7 +56,7 @@ Route::group('/common', function () {
|
||||
Route::any('/register', [app\common\controller\AuthController::class, 'register']);
|
||||
Route::any('/feishu/scope', [app\common\controller\AuthController::class, 'feishuScope']);
|
||||
Route::any('/refresh/token', [app\common\controller\AuthController::class, 'refresh']);
|
||||
|
||||
Route::any('/feishu/jsapi', [app\common\controller\AuthController::class, 'getJsApi']);
|
||||
|
||||
// // 调试
|
||||
Route::any('/feishu/auth', [app\common\controller\AuthController::class, 'feishuAuth']);
|
||||
@@ -518,6 +523,46 @@ Route::group('/admin', function () {
|
||||
Route::any('/destroy', [app\admin\controller\UserDashboardRuleController::class, 'destroy']);
|
||||
});
|
||||
});
|
||||
|
||||
// 物品领用
|
||||
Route::group('/inv', function () {
|
||||
Route::any('/item/list', [app\admin\controller\InvItemController::class, 'index']);
|
||||
Route::any('/item/store', [app\admin\controller\InvItemController::class, 'store']);
|
||||
Route::any('/item/update', [app\admin\controller\InvItemController::class, 'update']);
|
||||
Route::any('/item/show', [app\admin\controller\InvItemController::class, 'show']);
|
||||
Route::any('/item/destroy', [app\admin\controller\InvItemController::class, 'destroy']);
|
||||
Route::any('/item/qr-code', [app\admin\controller\InvItemController::class, 'generateQrCode']);
|
||||
Route::any('/item/qr-code/delete', [app\admin\controller\InvItemController::class, 'deleteQrCode']);
|
||||
|
||||
Route::group('/requisition', function () {
|
||||
Route::any('/list', [app\admin\controller\InvRequisitionController::class, 'index']);
|
||||
Route::any('/show', [app\admin\controller\InvRequisitionController::class, 'show']);
|
||||
Route::any('/approve', [app\admin\controller\InvRequisitionController::class, 'approve']);
|
||||
});
|
||||
|
||||
// 物品归还
|
||||
Route::group('/requisition/return', function () {
|
||||
Route::any('/store', [app\common\controller\InvRequisitionItemReturnController::class, 'store']);
|
||||
Route::any('/update', [app\common\controller\InvRequisitionItemReturnController::class, 'update']);
|
||||
Route::any('/list', [app\common\controller\InvRequisitionItemReturnController::class, 'index']);
|
||||
Route::any('/show', [app\common\controller\InvRequisitionItemReturnController::class, 'show']);
|
||||
Route::any('/confirm', [app\common\controller\InvRequisitionItemReturnController::class, 'returnConfirm']);
|
||||
// Route::any('/confirmItem', [app\common\controller\InvRequisitionItemReturnController::class, 'returnItemConfirm']);
|
||||
});
|
||||
});
|
||||
|
||||
// 库存变化记录
|
||||
Route::group('/stock-log', function () {
|
||||
Route::any('/list', [app\admin\controller\InvStockLogController::class, 'list']);
|
||||
});
|
||||
|
||||
Route::group('/requisition', function () {
|
||||
// Route::any('/list', [app\admin\controller\InvRequisitionController::class, 'index']);
|
||||
// Route::any('/show', [app\admin\controller\InvRequisitionController::class, 'show']);
|
||||
// Route::any('/approve', [app\admin\controller\InvRequisitionController::class, 'approve']);
|
||||
Route::any('/proxy-claim', [app\admin\controller\InvRequisitionController::class, 'proxyClaim']);
|
||||
Route::any('/qr-claims', [app\admin\controller\InvRequisitionController::class, 'qrClaims']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -639,9 +684,38 @@ Route::group('/crm', function () {
|
||||
});
|
||||
});
|
||||
|
||||
// 公共 API(API Key 鉴权;通过 noNeedLogin/noNeedHeaderData 跳过 JWT 和 BasicData)
|
||||
Route::group('/common/public', function () {
|
||||
Route::any('/pi/list', [app\common\controller\PublicApiController::class, 'piList']);
|
||||
Route::any('/vin/list', [app\common\controller\PublicApiController::class, 'vinList']);
|
||||
Route::any('/staff/list', [app\common\controller\PublicApiController::class, 'staffList']);
|
||||
})->middleware([app\middleware\AccessControl::class, app\middleware\ApiKeyAuth::class]);
|
||||
|
||||
|
||||
// 移动端
|
||||
Route::group('/mobile', function () {
|
||||
// 物品领用
|
||||
Route::group('/inv', function () {
|
||||
//物品下拉
|
||||
Route::any('/item/preset', [app\mobile\controller\InvItemController::class, 'preset']);
|
||||
|
||||
// 物品领用申请
|
||||
// Route::any('/requisition/list', [app\mobile\controller\InvRequisitionController::class, 'index']);
|
||||
// Route::any('/requisition/show', [app\mobile\controller\InvRequisitionController::class, 'show']);
|
||||
Route::any('/requisition/apply', [app\mobile\controller\InvRequisitionController::class, 'apply']);
|
||||
Route::any('/requisition/update', [app\mobile\controller\InvRequisitionController::class, 'update']);
|
||||
|
||||
|
||||
Route::group('/requisition', function () {
|
||||
Route::any('/list', [app\mobile\controller\InvRequisitionController::class, 'index']);
|
||||
Route::any('/show', [app\mobile\controller\InvRequisitionController::class, 'show']);
|
||||
// Route::any('/approve', [app\mobile\controller\InvRequisitionController::class, 'approve']);
|
||||
Route::any('/qr-claim', [app\mobile\controller\InvRequisitionController::class, 'qrClaim']);
|
||||
Route::any('/my-qr-claims', [app\mobile\controller\InvRequisitionController::class, 'myQrClaims']);
|
||||
});
|
||||
|
||||
// 物品归还
|
||||
Route::group('/requisition/return', function () {
|
||||
Route::any('/store', [app\mobile\controller\InvRequisitionItemReturnController::class, 'store']);
|
||||
Route::any('/update', [app\mobile\controller\InvRequisitionItemReturnController::class, 'update']);
|
||||
Route::any('/list', [app\mobile\controller\InvRequisitionItemReturnController::class, 'index']);
|
||||
Route::any('/show', [app\mobile\controller\InvRequisitionItemReturnController::class, 'show']);
|
||||
Route::any('/confirm', [app\mobile\controller\InvRequisitionItemReturnController::class, 'returnConfirm']);
|
||||
// Route::any('/confirmItem', [app\mobile\controller\InvRequisitionItemReturnController::class, 'returnItemConfirm']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// 共享密钥(双方约定同一个字符串)
|
||||
'shared_secret' => env('SYSTEM_AUTH_SHARED_SECRET', 'your-shared-secret-string'),
|
||||
|
||||
// 签名有效期(秒),默认10分钟
|
||||
'signature_expire' => 600,
|
||||
|
||||
// Token 有效期(秒)
|
||||
'token_expire' => 86400,
|
||||
];
|
||||
@@ -1,225 +0,0 @@
|
||||
# 备货单公共 API 对接文档
|
||||
|
||||
## 基础信息
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| Base URL | `https://your-domain.com` |
|
||||
| 鉴权方式 | 请求头 `Fdesk-Api-Key` |
|
||||
| 响应格式 | JSON |
|
||||
|
||||
## 鉴权
|
||||
|
||||
所有请求必须携带 API Key 请求头:
|
||||
|
||||
```
|
||||
Fdesk-Api-Key: <your-api-key>
|
||||
```
|
||||
|
||||
缺少或无效时返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "fail",
|
||||
"code": 401,
|
||||
"message": "无效的 API Key",
|
||||
"data": []
|
||||
}
|
||||
```
|
||||
|
||||
## 通用响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success | fail",
|
||||
"code": 200,
|
||||
"message": "描述信息",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
| code | 含义 |
|
||||
|------|------|
|
||||
| 200 | 成功 |
|
||||
| 400 | 查询失败 |
|
||||
| 401 | API Key 无效或缺失 |
|
||||
|
||||
---
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. PI 号列表
|
||||
|
||||
查询已审批通过的备货单 PI 号,含关联的车架号和车型。
|
||||
|
||||
**请求**
|
||||
|
||||
```
|
||||
GET /common/public/pi/list
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| page | int | 否 | 页码,默认 1 |
|
||||
| page_size | int | 否 | 每页条数,默认 20,最大 200 |
|
||||
| pi_no | string | 否 | PI 号筛选,模糊匹配 |
|
||||
|
||||
**响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"message": "请求成功",
|
||||
"data": {
|
||||
"total": 150,
|
||||
"per_page": 20,
|
||||
"current_page": 1,
|
||||
"data": [
|
||||
{
|
||||
"pi_no": "PI20250601001",
|
||||
"vins": ["LSVAU2A39N2123456", "LSVAU2A39N2123457"],
|
||||
"model_names": ["BYD Song Plus DM-i", "BYD Seal"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| pi_no | string | 备货单 PI 号 |
|
||||
| vins | string[] | 该 PI 下所有车架号 |
|
||||
| model_names | string[] | 该 PI 下所有车型名称(与 vins 一一对应) |
|
||||
|
||||
---
|
||||
|
||||
### 2. VIN 号列表
|
||||
|
||||
查询已审批通过的备货单车架号及对应车型。
|
||||
|
||||
**请求**
|
||||
|
||||
```
|
||||
GET /common/public/vin/list
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| page | int | 否 | 页码,默认 1 |
|
||||
| page_size | int | 否 | 每页条数,默认 20,最大 200 |
|
||||
| vin | string | 否 | 车架号筛选,模糊匹配 |
|
||||
|
||||
**响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"message": "请求成功",
|
||||
"data": {
|
||||
"total": 500,
|
||||
"per_page": 20,
|
||||
"current_page": 1,
|
||||
"data": [
|
||||
{
|
||||
"vin": "LSVAU2A39N2123456",
|
||||
"model_name": "BYD Song Plus DM-i"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| vin | string | 车架号 |
|
||||
| model_name | string | 车型名称 |
|
||||
|
||||
---
|
||||
|
||||
### 3. 业务人员列表
|
||||
|
||||
查询业务人员(角色 role_id=2,在职),全量返回。
|
||||
|
||||
**请求**
|
||||
|
||||
```
|
||||
GET /common/public/staff/list
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| keyword | string | 否 | 关键词,模糊匹配姓名/编号/电话/邮箱 |
|
||||
|
||||
**响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"message": "请求成功",
|
||||
"data": [
|
||||
{
|
||||
"id": 17,
|
||||
"name": "张三",
|
||||
"sn": "S0017",
|
||||
"phone": "13800138000",
|
||||
"email": "zhangsan@example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int | 用户ID |
|
||||
| name | string | 姓名 |
|
||||
| sn | string | 编号 |
|
||||
| phone | string | 电话 |
|
||||
| email | string | 邮箱 |
|
||||
|
||||
---
|
||||
|
||||
## 调用示例
|
||||
|
||||
```bash
|
||||
# 查询 PI 列表(第一页,每页10条)
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/pi/list?page=1&page_size=10"
|
||||
|
||||
# 按 PI 号搜索
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/pi/list?pi_no=PI202506"
|
||||
|
||||
# 查询 VIN 列表(第一页,每页50条)
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/vin/list?page=1&page_size=50"
|
||||
|
||||
# 按 VIN 号搜索
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/vin/list?vin=LSVAU2A3"
|
||||
|
||||
# 查询业务人员列表
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/staff/list?page=1&page_size=50"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 两个接口仅返回审批状态为"已通过"(approval_status=2)的备货单数据
|
||||
- 已删除的备货单不会出现在查询结果中
|
||||
- 接口为只读,不支持新增、修改、删除操作
|
||||
- `pi_no` 和 `vin` 筛选参数支持模糊匹配(包含即可)
|
||||
@@ -1,290 +0,0 @@
|
||||
# Public Stock Order API Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add two read-only public APIs (PI list, VIN list) for internal systems to query approved stock orders, authenticated via API Key.
|
||||
|
||||
**Architecture:** New `PublicApiController` in `app/common/controller/` with a dedicated `ApiKeyAuth` middleware. Routes registered in a standalone route group that bypasses JWT and BasicData middleware. Pagination via Eloquent's `paginate()`.
|
||||
|
||||
**Tech Stack:** PHP 8.1+, Webman framework, Eloquent ORM, illuminate/pagination
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Create | `app/middleware/ApiKeyAuth.php` | Validate `Fdesk-Api-Key` header against env config |
|
||||
| Create | `app/common/controller/PublicApiController.php` | Two endpoints: PI list, VIN list |
|
||||
| Modify | `config/route.php` | Add `/common/public/*` route group with ApiKeyAuth middleware |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create ApiKeyAuth Middleware
|
||||
|
||||
**Files:**
|
||||
- Create: `app/middleware/ApiKeyAuth.php`
|
||||
|
||||
- [ ] **Step 1: Create the middleware file**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace app\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
class ApiKeyAuth implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$apiKey = $request->header('Fdesk-Api-Key');
|
||||
$validKey = getenv('PUBLIC_API_KEY');
|
||||
|
||||
if (!$apiKey || !$validKey || $apiKey !== $validKey) {
|
||||
$body = [
|
||||
'status' => 'fail',
|
||||
'code' => 401,
|
||||
'message' => '无效的 API Key',
|
||||
'data' => [],
|
||||
];
|
||||
return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
return $handler($request);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add app/middleware/ApiKeyAuth.php
|
||||
git commit -m "feat: add ApiKeyAuth middleware for public API endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create PublicApiController
|
||||
|
||||
**Files:**
|
||||
- Create: `app/common/controller/PublicApiController.php`
|
||||
|
||||
- [ ] **Step 1: Create the controller file**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\model\StockOrders;
|
||||
use app\common\model\StockOrderItems;
|
||||
use support\Request;
|
||||
|
||||
class PublicApiController extends BaseController
|
||||
{
|
||||
protected $noNeedLogin = ['piList', 'vinList'];
|
||||
protected $noNeedHeaderData = ['piList', 'vinList'];
|
||||
|
||||
/**
|
||||
* PI 号列表(含关联的 VIN 和车型名称)
|
||||
* GET /common/public/pi/list
|
||||
*/
|
||||
public function piList(Request $request)
|
||||
{
|
||||
try {
|
||||
$page = (int)($request->input('page', 1));
|
||||
$pageSize = (int)($request->input('page_size', 20));
|
||||
$piNo = $request->input('pi_no', '');
|
||||
|
||||
if ($page < 1) $page = 1;
|
||||
if ($pageSize < 1 || $pageSize > 200) $pageSize = 20;
|
||||
|
||||
$query = StockOrders::where('approval_status', 2)
|
||||
->whereNotNull('pi_no')
|
||||
->where('pi_no', '<>', '');
|
||||
|
||||
if ($piNo !== '') {
|
||||
$query->where('pi_no', 'like', "%{$piNo}%");
|
||||
}
|
||||
|
||||
$query->with(['items' => function ($q) {
|
||||
$q->select('id', 'order_id', 'vin', 'model_snapshot');
|
||||
}]);
|
||||
|
||||
$paginator = $query->orderBy('id', 'desc')
|
||||
->paginate($pageSize, ['id', 'pi_no'], 'page', $page);
|
||||
|
||||
$data = $paginator->getCollection()->map(function ($order) {
|
||||
return [
|
||||
'pi_no' => $order->pi_no,
|
||||
'vins' => $order->items->pluck('vin')->filter()->values()->toArray(),
|
||||
'model_names' => $order->items->pluck('model_snapshot')->filter()->values()->toArray(),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return $this->success([
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'data' => $data,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VIN 号列表(含车型名称)
|
||||
* GET /common/public/vin/list
|
||||
*/
|
||||
public function vinList(Request $request)
|
||||
{
|
||||
try {
|
||||
$page = (int)($request->input('page', 1));
|
||||
$pageSize = (int)($request->input('page_size', 20));
|
||||
$vin = $request->input('vin', '');
|
||||
|
||||
if ($page < 1) $page = 1;
|
||||
if ($pageSize < 1 || $pageSize > 200) $pageSize = 20;
|
||||
|
||||
$query = StockOrderItems::whereHas('order', function ($q) {
|
||||
$q->where('approval_status', 2);
|
||||
})->whereNotNull('vin')
|
||||
->where('vin', '<>', '');
|
||||
|
||||
if ($vin !== '') {
|
||||
$query->where('vin', 'like', "%{$vin}%");
|
||||
}
|
||||
|
||||
$paginator = $query->orderBy('id', 'desc')
|
||||
->paginate($pageSize, ['id', 'vin', 'model_snapshot'], 'page', $page);
|
||||
|
||||
$data = $paginator->getCollection()->map(function ($item) {
|
||||
return [
|
||||
'vin' => $item->vin,
|
||||
'model_name' => $item->model_snapshot,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return $this->success([
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'data' => $data,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add app/common/controller/PublicApiController.php
|
||||
git commit -m "feat: add PublicApiController with PI and VIN list endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Register Routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/route.php`
|
||||
|
||||
- [ ] **Step 1: Add the route group**
|
||||
|
||||
在 `config/route.php` 文件末尾(最后一个 `});` 之后)追加:
|
||||
|
||||
```php
|
||||
// 公共 API(API Key 鉴权,不走 JWT / BasicData)
|
||||
Route::group('/common/public', function () {
|
||||
Route::any('/pi/list', [app\common\controller\PublicApiController::class, 'piList']);
|
||||
Route::any('/vin/list', [app\common\controller\PublicApiController::class, 'vinList']);
|
||||
})->middleware([app\middleware\AccessControl::class, app\middleware\ApiKeyAuth::class]);
|
||||
```
|
||||
|
||||
This route group only uses `AccessControl` (CORS) + `ApiKeyAuth`. It explicitly bypasses `BasicData` and `AuthCheck`.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add config/route.php
|
||||
git commit -m "feat: register public API routes with ApiKeyAuth middleware"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Configure API Key in Environment
|
||||
|
||||
**Files:**
|
||||
- Modify: `.env` (or equivalent environment config)
|
||||
|
||||
- [ ] **Step 1: Add the API key to the environment config**
|
||||
|
||||
Add to the `.env` file (or equivalent environment variable source):
|
||||
|
||||
```
|
||||
PUBLIC_API_KEY=your-secret-api-key-here
|
||||
```
|
||||
|
||||
Generate a real key, e.g.:
|
||||
|
||||
```bash
|
||||
php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"
|
||||
```
|
||||
|
||||
Use the generated value as the key.
|
||||
|
||||
- [ ] **Step 2: Verify the middleware reads it correctly**
|
||||
|
||||
The middleware uses `getenv('PUBLIC_API_KEY')`. Confirm this is consistent with how other env vars are read in the project (e.g., `getenv('DB_HOST')` in `config/database.php`).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Manual Verification
|
||||
|
||||
- [ ] **Step 1: Test PI list endpoint (no auth)**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8787/common/public/pi/list | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);'
|
||||
```
|
||||
|
||||
Expected: `{"status":"fail","code":401,"message":"无效的 API Key"}`
|
||||
|
||||
- [ ] **Step 2: Test PI list endpoint (with auth)**
|
||||
|
||||
```bash
|
||||
curl -s -H "Fdesk-Api-Key: your-secret-api-key-here" "http://localhost:8787/common/public/pi/list?page=1&page_size=5" | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);'
|
||||
```
|
||||
|
||||
Expected: JSON with `status: success`, `data` containing `total`, `per_page`, `current_page`, and array of `{pi_no, vins, model_names}`.
|
||||
|
||||
- [ ] **Step 3: Test VIN list endpoint (with auth)**
|
||||
|
||||
```bash
|
||||
curl -s -H "Fdesk-Api-Key: your-secret-api-key-here" "http://localhost:8787/common/public/vin/list?page=1&page_size=5" | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);'
|
||||
```
|
||||
|
||||
Expected: JSON with `status: success`, `data` containing array of `{vin, model_name}`.
|
||||
|
||||
- [ ] **Step 4: Test with pi_no filter**
|
||||
|
||||
```bash
|
||||
curl -s -H "Fdesk-Api-Key: your-secret-api-key-here" "http://localhost:8787/common/public/pi/list?pi_no=PI2025" | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);'
|
||||
```
|
||||
|
||||
Expected: filtered results or empty data array.
|
||||
|
||||
- [ ] **Step 5: Commit final verification**
|
||||
|
||||
No code changes expected. If fixes were needed during testing, commit them:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: adjustments from manual verification of public stock API"
|
||||
```
|
||||
@@ -1,107 +0,0 @@
|
||||
# Public Stock Order API Design
|
||||
|
||||
## Goal
|
||||
|
||||
Provide two read-only public APIs for internal systems to query approved stock order PI numbers and VIN numbers, with API Key authentication.
|
||||
|
||||
## Data Model
|
||||
|
||||
- `stock_orders.approval_status = 2` means approved
|
||||
- `stock_orders.pi_no` — PI number (string)
|
||||
- `stock_order_items.vin` — VIN/chassis number (string)
|
||||
- `stock_order_items.model_snapshot` — car model name (plain string)
|
||||
- `stock_order_items.order_id` → `stock_orders.id`
|
||||
|
||||
## Authentication
|
||||
|
||||
New middleware `ApiKeyAuth`:
|
||||
|
||||
- Read `Fdesk-Api-Key` header
|
||||
- Compare against key stored in `.env` (`API_KEY`) or `config/app.php`
|
||||
- Reject with 401 on mismatch or missing key
|
||||
- Bypass both `AuthCheck` (JWT) and `BasicData` middleware
|
||||
|
||||
## Endpoints
|
||||
|
||||
### A. PI Number List
|
||||
|
||||
```
|
||||
GET /common/public/pi/list
|
||||
Headers: Fdesk-Api-Key: <key>
|
||||
Query: page, page_size, pi_no (optional filter)
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"data": {
|
||||
"total": 150,
|
||||
"per_page": 20,
|
||||
"current_page": 1,
|
||||
"data": [
|
||||
{
|
||||
"pi_no": "PI20250601001",
|
||||
"vins": ["LSVAU2A39N2123456", "LSVAU2A39N2123457"],
|
||||
"model_names": ["Model Y", "Model 3"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Logic:
|
||||
1. Query `stock_orders` where `approval_status = 2` and `pi_no IS NOT NULL` and `pi_no != ''`
|
||||
2. Optional filter by `pi_no` (LIKE match)
|
||||
3. Paginate
|
||||
4. For each order, eager-load `items` → collect `vin` and `model_snapshot`
|
||||
5. Return aggregated result
|
||||
|
||||
### B. VIN Number List
|
||||
|
||||
```
|
||||
GET /common/public/vin/list
|
||||
Headers: Fdesk-Api-Key: <key>
|
||||
Query: page, page_size, vin (optional filter)
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"data": {
|
||||
"total": 500,
|
||||
"per_page": 20,
|
||||
"current_page": 1,
|
||||
"data": [
|
||||
{
|
||||
"vin": "LSVAU2A39N2123456",
|
||||
"model_name": "Model Y"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Logic:
|
||||
1. Query `stock_order_items` via `stock_orders` where `approval_status = 2`
|
||||
2. Optional filter by `vin` (LIKE match)
|
||||
3. Paginate
|
||||
4. Return `vin` + `model_snapshot` per item
|
||||
|
||||
## File Changes
|
||||
|
||||
| Action | File | Description |
|
||||
|--------|------|-------------|
|
||||
| New | `app/middleware/ApiKeyAuth.php` | API Key auth middleware |
|
||||
| New | `app/common/controller/PublicApiController.php` | Two endpoints |
|
||||
| Modify | `config/route.php` | Add `/common/public/*` route group with ApiKeyAuth |
|
||||
|
||||
## Constraints
|
||||
|
||||
- Read-only, no write operations
|
||||
- Only `approval_status = 2` records
|
||||
- Pagination required (default page_size = 20)
|
||||
- No JWT / BasicData middleware for these routes
|
||||
@@ -1,112 +0,0 @@
|
||||
# Inquiry Bulk Assign + Digest Email Design
|
||||
|
||||
## Goal
|
||||
|
||||
一次性将 `docs/崔琪2.xlsx` 中的 1427 条询盘,按 Excel 行顺序轮询分配给 6 个业务员(user_id: 34、73、72、155、64、211),更新 `crm_customer.user_id`,并为每人发送一封汇总邮件,列出分给其名下的全部询盘。
|
||||
|
||||
这是一次性数据操作,以 webman console 命令形式交付,**不做成永久 API / 控制器 / 定时任务**。
|
||||
|
||||
## Data Model(已核实)
|
||||
|
||||
- **匹配键**:Excel 第一列 `编号` = `crm_customer.code`(如 `CM815EE43E7D4C1C`)。
|
||||
- `crm_customer.code` —— varchar(32),**无唯一索引**(仅主键 + USER_ID/SN/TYPE 普通索引),匹配需防御撞多条。
|
||||
- `crm_customer.user_id` —— 待更新字段(`所属用户`,bigint unsigned nullable)。
|
||||
- `crm_customer.deleted_at` —— int 时间戳软删除标记,匹配需 `IS NULL`。
|
||||
- `crm_customer_his` —— 历史表,记录 `customer_id / user_his(原 user_id) / user_id(新)`,复用 `CustomerHis` model。
|
||||
- `user` 表:`id / name / email / corp_id / status`,6 人收件邮箱取自 `user.email`。
|
||||
- Excel 行数:1428(含表头)→ **1427 条询盘**,13 列(编号/录入人/接单人/渠道/邮箱/电话/WhatsApp/联系人/备注/国家(中)/国家(英)/备注_原始HTML/x)。
|
||||
|
||||
## 复用的现成组件(不引入新依赖)
|
||||
|
||||
- `app/command/CrmRotateUserCommand.php` —— 轮转 user_id + 事务 + 写 CustomerHis 的模板。
|
||||
- `app/common/utils/RedisUidRotator.php` —— 轮询分配器(本方案改用**内存确定性轮询**,见下)。
|
||||
- `app/common/utils/email/MailSend.php` —— `MailSend::send($to, $subject, $html, $attachments)`,SMTP 配置在 `config/imap.smtp_xyh`。
|
||||
- `app/admin/controller/InquiryTransmitController.php::emailHtml()` —— 现成询盘 HTML 邮件模板,改写为列表版复用。
|
||||
- `phpoffice/phpspreadsheet`(已在依赖)—— 读 Excel。
|
||||
|
||||
## Command Interface
|
||||
|
||||
```
|
||||
php webman inquiry:bulk-assign \
|
||||
--file=docs/崔琪2.xlsx \
|
||||
--users=34,73,72,155,64,211 \
|
||||
[--dry-run] # 只解析+匹配+分配,打印报告,不写库不发邮件(强制先跑)
|
||||
[--only-user=34] # 仅给指定 user_id 发邮件(单点试发)
|
||||
[--notify-only] # 跳过写库,按 runtime 清单补发未成功的邮件
|
||||
```
|
||||
|
||||
webman console 自动发现 `app/command/` 下命令,无需改 `config/command.php`。
|
||||
|
||||
## Execution Flow
|
||||
|
||||
### ① 解析 Excel(PhpSpreadsheet)
|
||||
读 Sheet1(跳过表头),按行顺序提取 `code` 及邮件所需字段:联系人、邮箱、电话、WhatsApp、国家(中)、渠道、备注_原始HTML。得到有序列表,长度 N≈1427。
|
||||
|
||||
### ② 匹配 crm_customer(关键防错)
|
||||
批量查询:
|
||||
```sql
|
||||
SELECT id, code, user_id FROM crm_customer
|
||||
WHERE code IN (...) AND deleted_at IS NULL
|
||||
```
|
||||
- 撞多条 code → 取 `MIN(id)`,记 warning;
|
||||
- Excel 有、库查不到(或仅软删除)→ 记入「未匹配清单」,**跳过不分配**(不中止);
|
||||
- 输出匹配率;保留 Excel 行顺序,记下每个客户原 `user_id`。
|
||||
|
||||
### ③ 内存轮询分配(确定性 `i % 6`)
|
||||
`targetUserIds = [34, 73, 72, 155, 64, 211]`,第 i 条匹配客户 → `targetUserIds[i % 6]`。
|
||||
**不依赖 Redis**(避免跨 run 状态残留、保证可重跑确定性),按目标用户分组得到每人清单。
|
||||
`--dry-run` 到此为止:打印匹配率、6 人条数(应 ≈238 均衡)、未匹配清单、抽样 3 条后退出。
|
||||
|
||||
### ④ 写库(单事务,可回滚)
|
||||
事务内对每条匹配客户:
|
||||
```sql
|
||||
UPDATE crm_customer SET user_id = ? WHERE id = ?;
|
||||
INSERT INTO crm_customer_his (customer_id, user_his, user_id) VALUES (?, ?, ?);
|
||||
```
|
||||
提交后才进入发邮件。`CustomerHis` 保留原归属以便回滚。
|
||||
|
||||
### ⑤ 发汇总邮件(每人 1 封,共 6 封)
|
||||
- 从 `user` 表取 6 人 `email/name`,空邮箱跳过并告警;
|
||||
- 复用 `emailHtml()` 改成列表版,把该用户分到的询盘做成 HTML 表格;
|
||||
- 每条展示字段:**编号 / 联系人 / 邮箱 / 电话 / 国家 / 渠道 / 意向车型或备注**;
|
||||
- 主题:`询盘分配汇总(共 N 条)- {name} - 2026-07-17`;
|
||||
- `MailSend::send($user_email, $subject, $html)`。
|
||||
|
||||
## Engineering Points
|
||||
|
||||
### (a) DB 与邮件解耦 + 防重复发送
|
||||
- 顺序:**先事务提交 → 再发邮件**(邮件不可回滚)。
|
||||
- 写运行清单 `runtime/inquiry_bulk_assign_2026-07-17.json`:分配结果 + 每人发送状态。
|
||||
- 重跑时:写库幂等(覆盖 user_id,会追加 CustomerHis 历史);**邮件步骤只给清单中未标记成功的人发**——杜绝跑两次双倍发。
|
||||
|
||||
### (b) 邮件失败不连坐
|
||||
`MailSend` 失败返回 false 并记日志。命令收集每人结果、失败重试 1 次,**最终汇总报告**,单封失败不中断(库已提交)。
|
||||
|
||||
### (c) 回滚
|
||||
- DB 事务失败 → 回滚,零影响。
|
||||
- 已提交但分错 → `crm_customer_his.user_his` 保留原归属,可反查/还原。
|
||||
- 邮件发出无法撤回 → 故**强制 dry-run**,并建议先 `--only-user` 单点试发。
|
||||
|
||||
## File Changes
|
||||
|
||||
| Action | File | Description |
|
||||
|--------|------|-------------|
|
||||
| New | `app/command/InquiryBulkAssignCommand.php` | 主命令:解析→匹配→分配→写库→发邮件,含 dry-run/only-user/notify-only |
|
||||
| New | `app/common/utils/email/InquiryDigestMail.php` | 汇总邮件 HTML 模板构建(基于 emailHtml 改写) |
|
||||
| Write | `runtime/inquiry_bulk_assign_2026-07-17.json` | 运行清单(分配结果 + 发送状态,防重复发,.gitignore 已忽略 runtime) |
|
||||
|
||||
不修改任何现有文件、不改路由、不改表结构。
|
||||
|
||||
## Verification
|
||||
|
||||
1. `--dry-run`:核对匹配率、6 人分配条数均衡、未匹配清单、抽样分配结果。
|
||||
2. `--only-user=34`:单点试发,确认模板与送达。
|
||||
3. 全量执行,检查运行清单 + 终端汇总报告。
|
||||
|
||||
## Constraints / Out of Scope
|
||||
|
||||
- 仅处理 `deleted_at IS NULL` 的活跃客户。
|
||||
- 未匹配 code 跳过 + 记清单,**不**中止整个流程。
|
||||
- 轮询顺序 = Excel 行顺序(用户已确认)。
|
||||
- 邮件粒度 = 每人一封汇总(用户已确认),不做逐条邮件。
|
||||
- 不做永久 API / Web 界面 / 定时任务;不引入新依赖。
|
||||
@@ -0,0 +1,94 @@
|
||||
CREATE TABLE `approval_flows` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(255) NOT NULL COMMENT '流程名称',
|
||||
`type` varchar(100) NOT NULL COMMENT '业务类型标识,如 leave, reimbursement',
|
||||
`steps` json NOT NULL COMMENT '审批步骤配置',
|
||||
`is_active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
|
||||
`timeout_hours` int(11) NOT NULL DEFAULT '0' COMMENT '实例级超时时间(小时),0为不超时',
|
||||
`timeout_action` varchar(20) NOT NULL DEFAULT 'auto_reject' COMMENT 'auto_approve | auto_reject',
|
||||
`created_at` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_flow_type` (`type`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COMMENT='审批流程定义';
|
||||
|
||||
CREATE TABLE `approval_instances` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`flow_type` varchar(100) NOT NULL COMMENT '关联 approval_flows.type',
|
||||
`biz_id` varchar(255) NOT NULL COMMENT '业务ID',
|
||||
`biz_type` varchar(100) NOT NULL COMMENT '业务标识/表名',
|
||||
`form_data` json NOT NULL COMMENT '提交表单快照',
|
||||
`initiator_id` bigint(20) unsigned NOT NULL COMMENT '发起人',
|
||||
`status` varchar(20) NOT NULL DEFAULT 'processing' COMMENT 'processing|approved|rejected|revoked|timed_out',
|
||||
`current_step` int(11) NOT NULL DEFAULT '0' COMMENT '当前步骤索引',
|
||||
`round` int(11) NOT NULL DEFAULT '1' COMMENT '当前轮次:1=首次,重提+1',
|
||||
`finished_at` timestamp NULL DEFAULT NULL,
|
||||
`deadline_at` timestamp NULL DEFAULT NULL,
|
||||
`created_at` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_flow_type` (`flow_type`),
|
||||
KEY `idx_biz` (`biz_type`,`biz_id`),
|
||||
KEY `idx_initiator` (`initiator_id`),
|
||||
KEY `idx_status_updated` (`status`,`updated_at`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=68 DEFAULT CHARSET=utf8mb4 COMMENT='审批实例';
|
||||
|
||||
CREATE TABLE `approval_records` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`instance_id` bigint(20) unsigned NOT NULL COMMENT 'approval_instances.id',
|
||||
`approver_id` bigint(20) unsigned NOT NULL COMMENT '审批人',
|
||||
`step_index` int(11) NOT NULL COMMENT '步骤索引',
|
||||
`round` int(11) NOT NULL DEFAULT '1' COMMENT '所属轮次',
|
||||
`action` varchar(20) DEFAULT NULL COMMENT 'approve|reject,待办时为NULL',
|
||||
`status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT 'pending|approved|rejected|skipped|timeout',
|
||||
`comment` text COMMENT '审批意见',
|
||||
`approved_at` timestamp NULL DEFAULT NULL COMMENT '审批时间/关闭时间',
|
||||
`created_at` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_instance_step_approver` (`instance_id`,`step_index`,`approver_id`),
|
||||
KEY `idx_instance_step_status` (`instance_id`,`step_index`,`status`),
|
||||
KEY `idx_approver` (`approver_id`),
|
||||
KEY `idx_instance_step_round` (`instance_id`,`step_index`,`round`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=105 DEFAULT CHARSET=utf8mb4 COMMENT='审批记录';
|
||||
|
||||
CREATE TABLE `approval_transfers` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`instance_id` bigint(20) unsigned NOT NULL,
|
||||
`step_index` int(11) NOT NULL,
|
||||
`record_id` bigint(20) unsigned NOT NULL COMMENT '转交后仍然有效的记录ID(若合并则为目标记录ID)',
|
||||
`from_user_id` bigint(20) unsigned NOT NULL,
|
||||
`to_user_id` bigint(20) unsigned NOT NULL,
|
||||
`reason` text,
|
||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_record` (`record_id`),
|
||||
KEY `idx_instance_step` (`instance_id`,`step_index`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批转交日志';
|
||||
|
||||
CREATE TABLE `approval_ccs` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`instance_id` bigint(20) unsigned NOT NULL,
|
||||
`user_id` bigint(20) unsigned NOT NULL,
|
||||
`notified_at` timestamp NULL DEFAULT NULL,
|
||||
`read_at` timestamp NULL DEFAULT NULL,
|
||||
`created_at` timestamp NULL DEFAULT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_instance_user` (`instance_id`,`user_id`),
|
||||
KEY `idx_user` (`user_id`),
|
||||
KEY `idx_instance` (`instance_id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COMMENT='审批抄送';
|
||||
|
||||
CREATE TABLE `approval_discussions` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`instance_id` bigint(20) unsigned NOT NULL,
|
||||
`user_id` bigint(20) unsigned NOT NULL,
|
||||
`is_internal` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0=公开,1=仅审批人可见',
|
||||
`content` text NOT NULL,
|
||||
`attachments` json DEFAULT NULL,
|
||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_instance_id` (`instance_id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='审批讨论';
|
||||
@@ -0,0 +1,174 @@
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- 1. 物品主数据表:增加 company_id
|
||||
DROP TABLE IF EXISTS inv_item;
|
||||
CREATE TABLE inv_item (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
company_id BIGINT(20) UNSIGNED DEFAULT NULL COMMENT '物品所属公司ID',
|
||||
item_code VARCHAR(64) NOT NULL COMMENT '物品编号(公司内部编码)',
|
||||
item_name VARCHAR(128) NOT NULL COMMENT '物品名称',
|
||||
category_id BIGINT(20) UNSIGNED DEFAULT NULL COMMENT '物品分类ID(可选,指向分类表)',
|
||||
spec_model VARCHAR(128) DEFAULT NULL COMMENT '规格型号',
|
||||
unit VARCHAR(32) DEFAULT '件' COMMENT '计量单位,如 件/盒/包',
|
||||
is_consumable TINYINT(1) DEFAULT 1 COMMENT '是否耗材:1耗材(不需归还),0非耗材(需归还)',
|
||||
stock_qty INT(11) DEFAULT 0 COMMENT '当前库存数量',
|
||||
manage_user_id BIGINT(20) UNSIGNED DEFAULT NULL COMMENT '物品管理员用户ID',
|
||||
location VARCHAR(255) DEFAULT NULL COMMENT '存放位置说明',
|
||||
remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
|
||||
created_at INT(11) NOT NULL DEFAULT 0 COMMENT '创建时间时间戳',
|
||||
updated_at INT(11) NOT NULL DEFAULT 0 COMMENT '更新时间时间戳',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_item_code (item_code),
|
||||
KEY idx_company (company_id),
|
||||
KEY idx_category (category_id),
|
||||
KEY idx_manage_user (manage_user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物品主数据表';
|
||||
|
||||
|
||||
-- 2. 物品领用/借用申请单(主表)
|
||||
DROP TABLE IF EXISTS inv_requisition;
|
||||
CREATE TABLE inv_requisition (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
order_no VARCHAR(64) NOT NULL COMMENT '申请单号',
|
||||
|
||||
applicant_company_id BIGINT(20) UNSIGNED DEFAULT NULL COMMENT '申请人所在公司ID(可选)',
|
||||
dept_id BIGINT(20) UNSIGNED DEFAULT NULL COMMENT '申请人部门ID',
|
||||
applicant_id BIGINT(20) UNSIGNED NOT NULL COMMENT '申请人用户ID',
|
||||
applicant_name VARCHAR(64) DEFAULT NULL COMMENT '申请人姓名快照',
|
||||
leader_id BIGINT(20) UNSIGNED DEFAULT NULL COMMENT '直属上级用户ID',
|
||||
manager_id BIGINT(20) UNSIGNED DEFAULT NULL COMMENT '物品管理员用户ID(主负责人)',
|
||||
|
||||
borrow_reason VARCHAR(255) NOT NULL COMMENT '领用/借用事由',
|
||||
use_start_date DATE NOT NULL COMMENT '借用/预计使用开始日期',
|
||||
use_end_date DATE DEFAULT NULL COMMENT '预计归还日期(耗材可为空)',
|
||||
|
||||
-- 0草稿 10待直属上级审批 20直属上级通过待物品管理员审批
|
||||
-- 30全部通过 40已驳回 50已取消
|
||||
status TINYINT(3) NOT NULL DEFAULT 10 COMMENT '审批状态',
|
||||
|
||||
is_returned TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已全部归还(针对需要归还的物品):0否 1是',
|
||||
|
||||
remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
|
||||
created_at INT(11) NOT NULL DEFAULT 0 COMMENT '创建时间时间戳',
|
||||
updated_at INT(11) NOT NULL DEFAULT 0 COMMENT '更新时间时间戳',
|
||||
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_order_no (order_no),
|
||||
KEY idx_applicant (applicant_id),
|
||||
KEY idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物品领用/借用申请单';
|
||||
|
||||
|
||||
-- 3. 申请单明细表:支持部分、多次归还
|
||||
DROP TABLE IF EXISTS inv_requisition_item;
|
||||
CREATE TABLE inv_requisition_item (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
requisition_id BIGINT(20) UNSIGNED NOT NULL COMMENT '申请单ID',
|
||||
item_id BIGINT(20) UNSIGNED NOT NULL COMMENT '物品ID',
|
||||
|
||||
item_name_snap VARCHAR(128) DEFAULT NULL COMMENT '物品名称快照',
|
||||
spec_model_snap VARCHAR(128) DEFAULT NULL COMMENT '规格型号快照',
|
||||
unit_snap VARCHAR(32) DEFAULT NULL COMMENT '单位快照',
|
||||
is_consumable_snap TINYINT(1) DEFAULT NULL COMMENT '是否耗材快照:1耗材 0非耗材',
|
||||
|
||||
request_qty INT(11) NOT NULL DEFAULT 1 COMMENT '申请数量',
|
||||
approved_qty INT(11) DEFAULT NULL COMMENT '实批数量(可与申请数量不同)',
|
||||
returned_qty INT(11) NOT NULL DEFAULT 0 COMMENT '已归还数量(累加)',
|
||||
|
||||
-- 对于非耗材:returned_qty >= approved_qty 时置为1
|
||||
is_finished TINYINT(1) NOT NULL DEFAULT 0 COMMENT '该物品是否已完成归还:0否 1是',
|
||||
|
||||
remark VARCHAR(255) DEFAULT NULL COMMENT '行备注',
|
||||
created_at INT(11) NOT NULL DEFAULT 0 COMMENT '创建时间时间戳',
|
||||
updated_at INT(11) NOT NULL DEFAULT 0 COMMENT '更新时间时间戳',
|
||||
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_req (requisition_id),
|
||||
KEY idx_item (item_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物品领用申请明细表';
|
||||
|
||||
|
||||
-- 4. 审批记录表(同上一版,略微无改动)
|
||||
DROP TABLE IF EXISTS inv_requisition_approve;
|
||||
CREATE TABLE inv_requisition_approve (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
requisition_id BIGINT(20) UNSIGNED NOT NULL COMMENT '申请单ID',
|
||||
node_type VARCHAR(32) NOT NULL COMMENT '审批节点类型:leader=直属上级,manager=物品管理员,other=其他',
|
||||
approver_id BIGINT(20) UNSIGNED NOT NULL COMMENT '审批人用户ID',
|
||||
approver_name VARCHAR(64) DEFAULT NULL COMMENT '审批人姓名快照',
|
||||
action TINYINT(3) NOT NULL COMMENT '审批动作:1通过 2驳回',
|
||||
comment VARCHAR(255) DEFAULT NULL COMMENT '审批意见',
|
||||
action_at INT(11) NOT NULL DEFAULT 0 COMMENT '审批时间时间戳',
|
||||
|
||||
created_at INT(11) NOT NULL DEFAULT 0 COMMENT '创建时间时间戳',
|
||||
updated_at INT(11) NOT NULL DEFAULT 0 COMMENT '更新时间时间戳',
|
||||
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_req (requisition_id),
|
||||
KEY idx_node (node_type),
|
||||
KEY idx_approver (approver_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物品领用申请审批记录表';
|
||||
|
||||
|
||||
-- 5. 申请单附件表(申请时上传)
|
||||
DROP TABLE IF EXISTS inv_requisition_attachment;
|
||||
CREATE TABLE inv_requisition_attachment (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
requisition_id BIGINT(20) UNSIGNED NOT NULL COMMENT '申请单ID',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '文件原始名称',
|
||||
file_path VARCHAR(255) NOT NULL COMMENT '文件存储路径/URL',
|
||||
file_size INT(11) DEFAULT NULL COMMENT '文件大小(字节)',
|
||||
created_at INT(11) NOT NULL DEFAULT 0 COMMENT '创建时间时间戳',
|
||||
updated_at INT(11) NOT NULL DEFAULT 0 COMMENT '更新时间时间戳',
|
||||
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_req (requisition_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物品领用申请附件表';
|
||||
|
||||
|
||||
/* ================= 归 还 相 关 表 ================= */
|
||||
|
||||
-- 6. 归还记录表:一次归还动作(可归还一个明细的一部分)
|
||||
DROP TABLE IF EXISTS inv_requisition_item_return;
|
||||
CREATE TABLE inv_requisition_item_return (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
requisition_id BIGINT(20) UNSIGNED NOT NULL COMMENT '申请单ID',
|
||||
requisition_item_id BIGINT(20) UNSIGNED NOT NULL COMMENT '申请单明细ID',
|
||||
item_id BIGINT(20) UNSIGNED NOT NULL COMMENT '物品ID',
|
||||
|
||||
return_qty INT(11) NOT NULL DEFAULT 0 COMMENT '本次归还数量',
|
||||
handler_id BIGINT(20) UNSIGNED NOT NULL COMMENT '办理归还的人(通常是物品管理员)',
|
||||
handler_name VARCHAR(64) DEFAULT NULL COMMENT '办理人姓名快照',
|
||||
|
||||
return_status TINYINT(3) NOT NULL DEFAULT 1 COMMENT '归还状态:1正常归还 2损坏 3其他',
|
||||
remark VARCHAR(255) DEFAULT NULL COMMENT '归还备注',
|
||||
|
||||
return_at INT(11) NOT NULL DEFAULT 0 COMMENT '归还时间时间戳',
|
||||
created_at INT(11) NOT NULL DEFAULT 0 COMMENT '创建时间时间戳',
|
||||
updated_at INT(11) NOT NULL DEFAULT 0 COMMENT '更新时间时间戳',
|
||||
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_req (requisition_id),
|
||||
KEY idx_req_item (requisition_item_id),
|
||||
KEY idx_item (item_id),
|
||||
KEY idx_handler (handler_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物品归还记录表(支持多次、部分归还)';
|
||||
|
||||
|
||||
-- 7. 归还附件表:归还时拍照留底
|
||||
DROP TABLE IF EXISTS inv_requisition_item_return_attachment;
|
||||
CREATE TABLE inv_requisition_item_return_attachment (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
return_id BIGINT(20) UNSIGNED NOT NULL COMMENT '归还记录ID',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '文件原始名称',
|
||||
file_path VARCHAR(255) NOT NULL COMMENT '文件存储路径/URL(通常为照片)',
|
||||
file_size INT(11) DEFAULT NULL COMMENT '文件大小(字节)',
|
||||
created_at INT(11) NOT NULL DEFAULT 0 COMMENT '创建时间时间戳',
|
||||
updated_at INT(11) NOT NULL DEFAULT 0 COMMENT '更新时间时间戳',
|
||||
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_return (return_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物品归还记录附件表(归还拍照留底)';
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
@@ -1,309 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
备货单成交统计报告生成器(固定脚本)
|
||||
|
||||
口径:
|
||||
成交 = 有备货单(stock_orders);时间按单据日期 biz_date;
|
||||
剔除 草稿/未提交(approval_status=0)、已拒绝(3)、已作废(4);排除软删除;
|
||||
初版与关联终版(object_id 互指)同月并存时只计终版,跨月各计各月;
|
||||
车况:明细 mileage>0 为二手车、=0 为新车;国家取清关目的国(去英文括号后缀)。
|
||||
|
||||
用法:
|
||||
python3 scripts/stock_deal_report.py --year 2026 --month 7 # 单月
|
||||
python3 scripts/stock_deal_report.py --from 2026-01 --to 2026-06 # 区间
|
||||
可选:--file runtime/fdesk-20260813.sql(默认)
|
||||
|
||||
输出:runtime/<期间>成交统计报告.pdf + runtime/<期间>成交详单.csv
|
||||
依赖:python3 + weasyprint(首次对每个 dump 建解析缓存,之后秒级重跑)
|
||||
"""
|
||||
import argparse, re, csv, html, os, pickle
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from weasyprint import HTML
|
||||
|
||||
TZ = timezone(timedelta(hours=8))
|
||||
EXCLUDE = {0, 3, 4} # 草稿/拒绝/作废
|
||||
DEFAULT_DUMP = 'runtime/fdesk-20260813.sql'
|
||||
|
||||
# ---------------- SQL dump 解析 ----------------
|
||||
def pvl(s, i):
|
||||
vals = []; cur = ''; instr = False; n = len(s)
|
||||
while i < n:
|
||||
c = s[i]
|
||||
if instr:
|
||||
if c == '\\' and i + 1 < n: cur += c + s[i + 1]; i += 2; continue
|
||||
if c == "'":
|
||||
if i + 1 < n and s[i + 1] == "'": cur += "''"; i += 2; continue
|
||||
cur += "'"; i += 1; instr = False; continue
|
||||
cur += c; i += 1; continue
|
||||
else:
|
||||
if c == "'": instr = True; cur += "'"; i += 1; continue
|
||||
if c == ',': vals.append(cur.strip()); cur = ''; i += 1; continue
|
||||
if c == ')': vals.append(cur.strip()); i += 1; break
|
||||
cur += c; i += 1
|
||||
return vals, i
|
||||
|
||||
def unesc(raw):
|
||||
raw = raw.strip()
|
||||
if raw.upper() == 'NULL': return None
|
||||
if len(raw) >= 2 and raw[0] == "'" and raw[-1] == "'":
|
||||
inner = raw[1:-1]; out = []; i = 0; L = len(inner)
|
||||
mp = {'n': '\n', 'r': '\r', 't': '\t', '0': '\x00', '\\': '\\', "'": "'", '"': '"', 'b': '\b', 'Z': '\x1a'}
|
||||
while i < L:
|
||||
c = inner[i]
|
||||
if c == '\\' and i + 1 < L: out.append(mp.get(inner[i + 1], inner[i + 1])); i += 2
|
||||
elif c == "'" and i + 1 < L and inner[i + 1] == "'": out.append("'"); i += 2
|
||||
else: out.append(c); i += 1
|
||||
return ''.join(out)
|
||||
try: return int(raw)
|
||||
except:
|
||||
try: return float(raw)
|
||||
except: return raw
|
||||
|
||||
def load_dump(path):
|
||||
"""返回 (orders: id->dict, items: [(order_id, model, qty, mileage)])"""
|
||||
cache = path + '.cache.pkl'
|
||||
if os.path.exists(cache):
|
||||
with open(cache, 'rb') as f:
|
||||
return pickle.load(f)
|
||||
cols = {}; cur_create = None
|
||||
orders = {}; items = []
|
||||
with open(path, encoding='utf-8', errors='replace') as f:
|
||||
for line in f:
|
||||
if cur_create is not None:
|
||||
m = re.match(r'\s*`(\w+)`\s', line)
|
||||
if m: cols[cur_create].append(m.group(1))
|
||||
if re.search(r'\)\s*ENGINE', line): cur_create = None
|
||||
continue
|
||||
if line.startswith('CREATE TABLE `'):
|
||||
t = line[14:line.index('`', 14)]
|
||||
if t in ('stock_orders', 'stock_order_items'):
|
||||
cur_create = t; cols[t] = []
|
||||
continue
|
||||
if line.startswith('INSERT INTO `stock_orders` VALUES ('):
|
||||
v = [unesc(x) for x in pvl(line, line.index('VALUES (') + 8)[0]]
|
||||
ci = {c: i for i, c in enumerate(cols['stock_orders'])}
|
||||
orders[v[ci['id']]] = dict(
|
||||
order_no=v[ci['order_no']], biz=v[ci['biz_date']] or 0,
|
||||
fin=v[ci['is_final']], oid=v[ci['object_id']] or 0,
|
||||
ap=v[ci['approval_status']], deleted=v[ci['deleted_at']] is not None,
|
||||
country=str(v[ci['clearance_country_snapshot']] or '').strip(),
|
||||
sales=str(v[ci['salesperson_snapshot']] or '').strip(),
|
||||
)
|
||||
elif line.startswith('INSERT INTO `stock_order_items` VALUES ('):
|
||||
v = [unesc(x) for x in pvl(line, line.index('VALUES (') + 8)[0]]
|
||||
ci = {c: i for i, c in enumerate(cols['stock_order_items'])}
|
||||
items.append((v[ci['order_id']], str(v[ci['model_snapshot']] or '').strip(),
|
||||
int(v[ci['quantity']] or 0), int(v[ci['mileage']] or 0)))
|
||||
with open(cache, 'wb') as f:
|
||||
pickle.dump((orders, items), f)
|
||||
return orders, items
|
||||
|
||||
# ---------------- 统计 ----------------
|
||||
_PAREN = re.compile(r'\((?:[^()]|\([^()]*\))*\)\s*$')
|
||||
|
||||
def clean_country(c):
|
||||
while True:
|
||||
c2 = _PAREN.sub('', c).strip()
|
||||
if c2 == c: return c
|
||||
c = c2
|
||||
|
||||
def month_key(ts):
|
||||
return datetime.fromtimestamp(ts, TZ).strftime('%Y-%m')
|
||||
|
||||
def build_report(orders, items, months):
|
||||
"""months: [(y, m), ...] 连续月份列表"""
|
||||
for o in orders.values():
|
||||
o['country'] = clean_country(o['country'])
|
||||
|
||||
t0 = int(datetime(months[0][0], months[0][1], 1, tzinfo=TZ).timestamp())
|
||||
ly, lm = months[-1]
|
||||
t1 = int((datetime(ly, lm, 1, tzinfo=TZ) + timedelta(days=32)).replace(day=1).timestamp())
|
||||
|
||||
def in_range(o):
|
||||
return (not o['deleted']) and t0 <= o['biz'] < t1
|
||||
|
||||
cand = {i: o for i, o in orders.items() if in_range(o) and o['ap'] not in EXCLUDE}
|
||||
excluded = {a: sum(1 for o in orders.values() if in_range(o) and o['ap'] == a) for a in sorted(EXCLUDE)}
|
||||
|
||||
# 初终版去重(按月):初版的关联终版与初版同月 -> 跳过初版
|
||||
sel = {}; skipped = []; cross_month_fin = 0
|
||||
for i, o in cand.items():
|
||||
if o['fin'] == 0 and o['oid'] > 0:
|
||||
fin = orders.get(o['oid'])
|
||||
if fin and fin['fin'] == 1 and not fin['deleted']:
|
||||
if month_key(fin['biz']) == month_key(o['biz']):
|
||||
skipped.append(i); continue
|
||||
cross_month_fin += 1
|
||||
sel[i] = o
|
||||
|
||||
order_ids = set(sel)
|
||||
items_by_order = defaultdict(list)
|
||||
for oid, model, qty, mile in items:
|
||||
if oid in order_ids:
|
||||
items_by_order[oid].append((model, qty, mile))
|
||||
|
||||
by_model = Counter(); by_mc = defaultdict(Counter); by_country = Counter()
|
||||
by_sales = Counter(); by_sales_orders = Counter(); by_cond = Counter()
|
||||
by_model_cond = defaultdict(Counter); by_country_cond = defaultdict(Counter)
|
||||
by_sales_cond = defaultdict(Counter); by_month = defaultdict(lambda: Counter())
|
||||
detail = []
|
||||
|
||||
for oid, o in sel.items():
|
||||
sp = o['sales'] or '(未填)'
|
||||
c = o['country'] or '(未填)'
|
||||
mk = month_key(o['biz'])
|
||||
by_sales_orders[sp] += 1
|
||||
by_month[mk]['orders'] += 1
|
||||
for model, qty, mile in items_by_order.get(oid, []):
|
||||
m = model or '(未填车型)'
|
||||
cd = '二手车' if (mile or 0) > 0 else '新车'
|
||||
by_model[m] += qty; by_mc[m][c] += qty; by_country[c] += qty; by_sales[sp] += qty
|
||||
by_cond[cd] += qty
|
||||
by_model_cond[m][cd] += qty; by_country_cond[c][cd] += qty; by_sales_cond[sp][cd] += qty
|
||||
by_month[mk][cd] += qty; by_month[mk]['veh'] += qty
|
||||
detail.append(dict(model=m, country=c, qty=qty, sales=sp, month=mk,
|
||||
cond=cd if cd == '新车' else f'二手车({mile:,}km)',
|
||||
order_no=o['order_no'], date=datetime.fromtimestamp(o['biz'], TZ).strftime('%m-%d')))
|
||||
detail.sort(key=lambda r: (r['month'], r['sales'], r['order_no']))
|
||||
return dict(sel=sel, excluded=excluded, skipped=skipped, cross=cross_month_fin,
|
||||
by_model=by_model, by_mc=by_mc, by_country=by_country, by_sales=by_sales,
|
||||
by_sales_orders=by_sales_orders, by_cond=by_cond,
|
||||
by_model_cond=by_model_cond, by_country_cond=by_country_cond,
|
||||
by_sales_cond=by_sales_cond, by_month=by_month, detail=detail)
|
||||
|
||||
# ---------------- 渲染 ----------------
|
||||
def esc(x): return html.escape(str(x))
|
||||
def tr(rows):
|
||||
return ''.join('<tr><td>' + '</td><td>'.join(esc(c) for c in r) + '</td></tr>' for r in rows)
|
||||
|
||||
def render_pdf(R, months, label, out_pdf):
|
||||
total = sum(R['by_model'].values())
|
||||
new_n = R['by_cond'].get('新车', 0); used_n = R['by_cond'].get('二手车', 0)
|
||||
multi = len(months) > 1
|
||||
|
||||
model_rows = [(m, R['by_model_cond'][m].get('新车', 0), R['by_model_cond'][m].get('二手车', 0), n,
|
||||
'、'.join(f'{c}×{k}' for c, k in R['by_mc'][m].most_common()))
|
||||
for m, n in sorted(R['by_model'].items(), key=lambda x: -x[1])]
|
||||
country_rows = [(c, R['by_country_cond'][c].get('新车', 0), R['by_country_cond'][c].get('二手车', 0), n, f'{n / total * 100:.1f}%')
|
||||
for c, n in R['by_country'].most_common()]
|
||||
sales_rows = [(s, R['by_sales_orders'][s], R['by_sales_cond'][s].get('新车', 0), R['by_sales_cond'][s].get('二手车', 0),
|
||||
R['by_sales'][s], f'{R["by_sales"][s] / total * 100:.1f}%')
|
||||
for s, _ in sorted(R['by_sales'].items(), key=lambda x: -R['by_sales'][x[0]])]
|
||||
month_rows = [(mk, c['orders'], c['veh'], c.get('新车', 0), c.get('二手车', 0))
|
||||
for mk, c in sorted(R['by_month'].items())]
|
||||
dcol = [(r['sales'], r['month'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']) for r in R['detail']] \
|
||||
if multi else \
|
||||
[(r['sales'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']) for r in R['detail']]
|
||||
|
||||
sec = []
|
||||
if multi:
|
||||
sec.append(('月度分布', '<table><thead><tr><th style="width:14%">月份</th><th style="width:14%">单数</th>'
|
||||
'<th style="width:16%">车辆数</th><th style="width:14%">新车</th><th>二手车</th></tr></thead>'
|
||||
f'<tbody>{tr(month_rows)}</tbody></table>'))
|
||||
sec.append(('车型成交排行', '<table><thead><tr><th style="width:36%">车型</th><th style="width:6%">新车</th>'
|
||||
'<th style="width:6%">二手</th><th style="width:6%">合计</th><th>成交国家(国家×辆数)</th></tr></thead>'
|
||||
f'<tbody>{tr(model_rows)}</tbody></table>'))
|
||||
sec.append(('国家成交排行', '<table><thead><tr><th style="width:22%">国家(清关目的国)</th><th style="width:8%">新车</th>'
|
||||
'<th style="width:8%">二手</th><th style="width:8%">合计</th><th>占比</th></tr></thead>'
|
||||
f'<tbody>{tr(country_rows)}</tbody></table>'))
|
||||
sec.append(('业务员成交排行', '<table><thead><tr><th style="width:14%">业务员</th><th style="width:8%">单数</th>'
|
||||
'<th style="width:8%">新车</th><th style="width:8%">二手</th><th style="width:10%">辆数合计</th>'
|
||||
'<th>占车辆数比</th></tr></thead>'
|
||||
f'<tbody>{tr(sales_rows)}</tbody></table>'))
|
||||
dhead = ('<th style="width:8%">业务员</th><th style="width:7%">月份</th><th>车型</th><th style="width:10%">国家</th>'
|
||||
'<th style="width:11%">车况</th><th style="width:5%">数量</th><th style="width:12%">单号</th><th style="width:7%">单据日期</th>') if multi else \
|
||||
('<th style="width:8%">业务员</th><th>车型</th><th style="width:10%">国家</th><th style="width:11%">车况</th>'
|
||||
'<th style="width:5%">数量</th><th style="width:12%">单号</th><th style="width:7%">单据日期</th>')
|
||||
sec.append(('成交详单(按月份、业务员)', f'<table><thead><tr>{dhead}</tr></thead><tbody>{tr(dcol)}</tbody></table>'))
|
||||
sections = ''.join(f'<h2>{i}、{t}</h2>{b}' for i, (t, b) in enumerate(sec, 1))
|
||||
|
||||
ex = R['excluded']
|
||||
doc = f"""<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
@page {{ size: A4 landscape; margin: 1.3cm; }}
|
||||
body {{ font-family:"WenQuanYi Zen Hei","Noto Sans CJK SC",sans-serif; color:#222; font-size:10.5px; }}
|
||||
h1 {{ font-size:19px; margin:0 0 2px; }}
|
||||
.meta {{ color:#666; font-size:9px; margin-bottom:5px; }}
|
||||
.summary {{ background:#f5f7fa; border:1px solid #e3e8ef; padding:7px 10px; border-radius:4px; margin:5px 0 10px; font-size:10.5px; line-height:1.7; }}
|
||||
h2 {{ font-size:12.5px; margin:12px 0 5px; border-left:4px solid #BB0010; padding-left:7px; }}
|
||||
table {{ border-collapse:collapse; width:100%; margin-bottom:4px; }}
|
||||
th,td {{ border:1px solid #cfd6e0; padding:3px 5px; vertical-align:top; font-size:9px; }}
|
||||
th {{ background:#eef1f6; text-align:left; }}
|
||||
thead {{ display:table-header-group; }}
|
||||
tr {{ page-break-inside:avoid; }}
|
||||
.kpis {{ width:100%; margin:8px 0 10px; }}
|
||||
.kpis td {{ border:none; text-align:center; padding:6px 2px; }}
|
||||
.kpis .v {{ font-size:19px; font-weight:bold; color:#BB0010; }}
|
||||
.kpis .l {{ font-size:9px; color:#666; }}
|
||||
.foot {{ color:#888; font-size:8.5px; margin-top:8px; line-height:1.6; }}
|
||||
</style></head><body>
|
||||
<h1>备货单成交统计报告 · {label}</h1>
|
||||
<div class="meta">数据来源:stock_orders / stock_order_items · 生成日期 {datetime.now(TZ).date().isoformat()} · 统计口径:有备货单即成交</div>
|
||||
<div class="summary"><b>口径说明:</b>单据日期(biz_date)落在 {label};已剔除 草稿/未提交({ex.get(0, 0)} 单)、已拒绝({ex.get(3, 0)} 单)、已作废({ex.get(4, 0)} 单);
|
||||
初版与关联终版(object_id 互指)同月并存时只计终版(本期跳过初版 {len(R['skipped'])} 单);跨月关联终版 {R['cross']} 单按所属月份计入。
|
||||
<b>车况判定:</b>系统无显式新车/二手车字段,按明细 mileage 判定:里程>0 为<b>二手车</b>、=0 为新车。国家按清关目的国。</div>
|
||||
<table class="kpis"><tr>
|
||||
<td><div class="v">{len(R['sel'])}</div><div class="l">成交单数</div></td>
|
||||
<td><div class="v">{total}</div><div class="l">成交车辆数</div></td>
|
||||
<td><div class="v">{new_n}</div><div class="l">新车(辆)</div></td>
|
||||
<td><div class="v">{used_n}</div><div class="l">二手车(辆)</div></td>
|
||||
<td><div class="v">{len(R['by_model'])}</div><div class="l">成交车型数</div></td>
|
||||
<td><div class="v">{len(R['by_country'])}</div><div class="l">覆盖国家数</div></td>
|
||||
</tr></table>
|
||||
{sections}
|
||||
<p class="foot">注:明细按 stock_order_items 逐行展开(数量取 quantity);车况括号内为二手车明细里程(km);业务员为建单时快照(salesperson_snapshot)。</p>
|
||||
</body></html>"""
|
||||
HTML(string=doc).write_pdf(out_pdf)
|
||||
|
||||
def write_csv(R, label, multi):
|
||||
path = f'runtime/{label}成交详单.csv'
|
||||
with open(path, 'w', newline='', encoding='utf-8-sig') as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(['业务员', '月份', '车型', '国家', '车况', '数量', '单号', '单据日期'] if multi
|
||||
else ['业务员', '车型', '国家', '车况', '数量', '单号', '单据日期'])
|
||||
for r in R['detail']:
|
||||
row = [r['sales'], r['month'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']] if multi \
|
||||
else [r['sales'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']]
|
||||
w.writerow(row)
|
||||
return path
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='备货单成交统计报告')
|
||||
ap.add_argument('--file', default=DEFAULT_DUMP, help='Navicat 全库 dump 路径')
|
||||
g = ap.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument('--year', type=int); ap.add_argument('--month', type=int, default=None)
|
||||
g.add_argument('--frm', '--from', dest='frm', default=None, help='起始月 2026-01')
|
||||
ap.add_argument('--to', default=None, help='结束月 2026-06')
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.frm:
|
||||
y1, m1 = map(int, args.frm.split('-'))
|
||||
y2, m2 = map(int, (args.to or args.frm).split('-'))
|
||||
months = []
|
||||
y, m = y1, m1
|
||||
while (y, m) <= (y2, m2):
|
||||
months.append((y, m)); m += 1
|
||||
if m == 13: y, m = y + 1, 1
|
||||
else:
|
||||
months = [(args.year, args.month or datetime.now(TZ).month)]
|
||||
|
||||
if len(months) == 1:
|
||||
y, m = months[0]; label = f'{y}年{m}月'
|
||||
else:
|
||||
(y1, m1), (y2, m2) = months[0], months[-1]
|
||||
label = f'{y1}年{m1}-{m2}月' if y1 == y2 else f'{y1}年{m1}月-{y2}年{m2}月'
|
||||
|
||||
orders, items = load_dump(args.file)
|
||||
print(f'dump: {args.file}(orders {len(orders)} / items {len(items)}),期间: {label}')
|
||||
R = build_report(orders, items, months)
|
||||
total = sum(R['by_model'].values())
|
||||
print(f"入选 {len(R['sel'])} 单 / {total} 辆(新车 {R['by_cond'].get('新车', 0)} / 二手 {R['by_cond'].get('二手车', 0)}),"
|
||||
f"车型 {len(R['by_model'])},国家 {len(R['by_country'])},剔除 {R['excluded']},同月终版跳过初版 {len(R['skipped'])} 单")
|
||||
pdf = f'runtime/{label}成交统计报告.pdf'
|
||||
render_pdf(R, months, label, pdf)
|
||||
csvp = write_csv(R, label, len(months) > 1)
|
||||
print('已生成:', pdf, '|', csvp)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user