Merge branch 'feature/inv' into develop

This commit is contained in:
2026-02-24 11:28:21 +08:00
18 changed files with 1192 additions and 51 deletions
+81 -3
View File
@@ -40,8 +40,8 @@ class InvItemController extends BaseController
* @param array $data
* @return array
*/
protected function indexFormat($data)
{
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']))
@@ -52,4 +52,82 @@ class InvItemController extends BaseController
$data['manage_user_name'] = User::where('id', $data['manage_user_id'])->value('name');
return $data;
}
}
/**
* 生成物品二维码
*
* @param Request $request
* @return mixed
*/
public function generateQrCode(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('物品不存在');
}
// 获取应用访问地址
$appUrl = config('app.app_url', 'http://127.0.0.1:25616');
$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_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 = null;
$item->qr_code_url = null;
$item->save();
return $this->success([], '二维码删除成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
}
@@ -145,4 +145,36 @@ class InvRequisitionController extends BaseController
}
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,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());
}
}
}
@@ -98,4 +98,22 @@ class InvRequisitionService
//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);
}
}
+3 -2
View File
@@ -40,8 +40,9 @@ class InvItem extends Model
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'company_id', 'item_code', 'item_name',
'category_id', 'spec_model', 'unit', 'is_consumable', 'stock_qty', 'manage_user_id', 'location', 'remark',
protected $fillable = ['id', 'company_id', 'item_code', 'item_name',
'category_id', 'spec_model', 'unit', 'is_consumable', 'allow_qr_claim', 'qr_code', 'qr_code_url',
'stock_qty', 'manage_user_id', 'location', 'remark',
'sort', 'status',
'created_at', 'updated_at'];
+25 -7
View File
@@ -40,24 +40,42 @@ class InvRequisition extends Model
/**
* 允许自动填充模型字段
*/
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', 'is_returned', 'remark', 'created_at', 'updated_at'];
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; // 管理员代领
const STATUS_DRAFT = 0; // 草稿
const STATUS_LEADER = 10; // 待直属上级审批
const STATUS_MANAGE = 20; // 待管理员审批
const STATUS_APPROVED = 30; // 已通过
const STATUS_REJECTEDT = 40; // 已驳回
const STATUS_CANCELED = 50; // 已取消
/**
* 领取方式文本
*/
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');
}
}
+81
View File
@@ -0,0 +1,81 @@
<?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',
'created_at',
];
protected $casts = [
'created_at' => 'timestamp',
];
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');
}
}
+84 -10
View File
@@ -124,16 +124,28 @@ class InvItemService
* @param int $itemId 物品ID
* @param int $changeQty 变动数量:>0 入库,<0 出库
* @param bool $allowNegative 是否允许库存变成负数,默认不允许
*
* @param array $options 额外选项(用于记录日志)
* - biz_type: 业务类型
* - biz_no: 单据号
* - operator_id: 操作人ID
* - operator_name: 操作人姓名
* - remark: 备注
* @return InvItem 最新的物品记录
* @throws BusinessException
*/
public function changeStock(int $itemId, int $changeQty, bool $allowNegative = false): InvItem
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();
// 构建查询
@@ -154,39 +166,101 @@ class InvItemService
// 更新不到行:
// 1) 物品不存在
// 2) 或者库存不足(不允许负数时)
$exists = InvItem::query()->where('id', $itemId)->exists();
if (! $exists) {
throw new BusinessException('物品不存在');
}
throw new BusinessException('库存不足,无法完成库存变动');
}
// 再查一次最新数据返回(在外层事务中也会保持一致)
/** @var InvItem $item */
$item = InvItem::query()->find($itemId);
// 记录库存变化日志
$this->recordStockLog([
'item_id' => $itemId,
'item_name_snap' => $item->item_name,
'change_qty' => $changeQty,
'before_qty' => $beforeQty,
'after_qty' => $item->stock_qty,
'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): InvItem
public function stockOut(int $itemId, int $qty, bool $allowNegative = false, array $options = []): InvItem
{
if ($qty <= 0) {
throw new BusinessException('出库数量必须大于 0');
}
return $this->changeStock($itemId, -$qty, $allowNegative);
// 确定变动类型: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): InvItem
public function stockIn(int $itemId, int $qty, array $options = []): InvItem
{
if ($qty <= 0) {
throw new BusinessException('入库数量必须大于 0');
}
return $this->changeStock($itemId, $qty, true);
// 确定变动类型: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());
}
}
/**
@@ -394,7 +394,7 @@ class InvRequisitionItemReturnService
public function serviceReturnItemConfirm(Request $request, array $data, string $scene = 'returnConfirm')
{
try {
$attachment = $request->post('attachment');
$attachment = $request->post('attachment') ?? [];
unset($data['attachment']);
// 归还确认是业务顶层方法,这里统一使用事务
return Db::transaction(function () use ($request, $data, $scene, $attachment) {
@@ -555,7 +555,7 @@ class InvRequisitionItemReturnService
public function serviceReturnConfirm(Request $request, array $data, string $scene = 'returnConfirm')
{
try {
$attachment = $request->post('attachment');
$attachment = $request->post('attachment') ?? [];
unset($data['attachment']);
// 归还确认是业务顶层方法,这里统一使用事务
return Db::transaction(function () use ($request, $data, $scene, $attachment) {
@@ -3,6 +3,7 @@
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;
@@ -313,4 +314,268 @@ class InvRequisitionService
{
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->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);
$targetUserId = (int)($data['target_user_id'] ?? 0);
if ($itemId <= 0) {
throw new BusinessException('物品ID无效');
}
if ($qty <= 0) {
throw new BusinessException('领取数量必须大于0');
}
if ($targetUserId <= 0) {
throw new BusinessException('请选择代领用户');
}
// 获取物品信息
$item = InvItem::find($itemId);
if (!$item) {
throw new BusinessException('物品不存在');
}
// 获取操作人(管理员)信息
$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('您不是该物品的管理员,无权代领');
}
// 获取目标用户信息
$targetUser = (new UserService())->getUserInfo($targetUserId, '');
if (!$targetUser['user']) {
throw new BusinessException('目标用户不存在');
}
// 检查库存
if ($item->stock_qty < $qty) {
throw new BusinessException('库存不足,当前库存: ' . $item->stock_qty);
}
// 创建领用申请记录(直接完成)
$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' => $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' => $data['remark'] ?? '管理员代领',
]);
// 扣减库存
(new InvItemService())->stockOut($itemId, $qty, false, [
'biz_type' => InvStockLog::BIZ_TYPE_PROXY_CLAIM,
'biz_no' => $requisition->order_no,
'operator_id' => $proxyUserId,
'operator_name' => $proxyUserName,
'remark' => '管理员代领',
]);
return $requisition->toArray();
});
}
/**
* 查询扫码/代领记录列表
*
* @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'));
}
// 时间范围筛选
if (!empty($request->get('start_time'))) {
$query->where('qr_claim_time', '>=', strtotime($request->get('start_time')));
}
if (!empty($request->get('end_time'))) {
$query->where('qr_claim_time', '<=', 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(),
];
}
}
+118
View File
@@ -0,0 +1,118 @@
<?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,
'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', $log->created_at),
];
})->toArray(),
'total' => $result->total(),
'current' => $result->currentPage(),
'size' => $result->perPage(),
];
}
}
+186
View File
@@ -0,0 +1,186 @@
<?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;
}
/**
* 生成二维码图片并上传到 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;
}
}
}
+11 -6
View File
@@ -8,22 +8,27 @@ class InvItemValidate extends Validate
{
// 规则
protected $rule = [
'id' => 'require|integer',
];
// 提示
protected $message = [
'id.require' => '物品ID不能为空',
'id.integer' => '物品ID必须为整数',
];
// 场景
protected $scene = [
'store' => [
'id',
],
'update' => [
]
'id',
],
'qrCode' => [
'id',
],
];
}
}
+38 -4
View File
@@ -15,6 +15,11 @@ class InvRequisitionValidate extends Validate
'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' => 'require|integer|gt:0',
];
// 提示
@@ -32,24 +37,53 @@ class InvRequisitionValidate extends Validate
'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' => '代领用户不能为空',
'target_user_id.integer' => '代领用户ID必须为整数',
'target_user_id.gt' => '代领用户ID必须大于0',
];
// 场景
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',
'borrow_reason',
],
];
}
@@ -147,4 +147,38 @@ class InvRequisitionController extends BaseController
}
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());
}
}
}
+2 -1
View File
@@ -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
View File
@@ -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",
+26 -15
View File
@@ -526,22 +526,31 @@ Route::group('/admin', function () {
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('/stock-log', function () {
Route::any('/list', [app\admin\controller\InvStockLogController::class, 'list']);
});
// 物品归还
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('/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']);
});
// 物品归还
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']);
});
});
@@ -684,6 +693,8 @@ Route::group('/mobile', 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']);
});
// 物品归还
@@ -693,7 +704,7 @@ Route::group('/mobile', function () {
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']);
// Route::any('/confirmItem', [app\mobile\controller\InvRequisitionItemReturnController::class, 'returnItemConfirm']);
});
});
});