diff --git a/app/admin/controller/InvItemController.php b/app/admin/controller/InvItemController.php index ed8866f..09e5713 100644 --- a/app/admin/controller/InvItemController.php +++ b/app/admin/controller/InvItemController.php @@ -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; } -} \ No newline at end of file + + /** + * 生成物品二维码 + * + * @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()); + } + } +} diff --git a/app/admin/controller/InvRequisitionController.php b/app/admin/controller/InvRequisitionController.php index bdb9411..3827937 100644 --- a/app/admin/controller/InvRequisitionController.php +++ b/app/admin/controller/InvRequisitionController.php @@ -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()); + } + } } \ No newline at end of file diff --git a/app/admin/controller/InvStockLogController.php b/app/admin/controller/InvStockLogController.php new file mode 100644 index 0000000..00cfbc2 --- /dev/null +++ b/app/admin/controller/InvStockLogController.php @@ -0,0 +1,32 @@ +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()); + } + } +} diff --git a/app/admin/service/InvRequisitionService.php b/app/admin/service/InvRequisitionService.php index c422d9a..e23830f 100644 --- a/app/admin/service/InvRequisitionService.php +++ b/app/admin/service/InvRequisitionService.php @@ -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); + } } \ No newline at end of file diff --git a/app/common/model/InvItem.php b/app/common/model/InvItem.php index 36f1be3..c6dfa32 100644 --- a/app/common/model/InvItem.php +++ b/app/common/model/InvItem.php @@ -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']; diff --git a/app/common/model/InvRequisition.php b/app/common/model/InvRequisition.php index 493a102..591a8c6 100644 --- a/app/common/model/InvRequisition.php +++ b/app/common/model/InvRequisition.php @@ -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'); + } } \ No newline at end of file diff --git a/app/common/model/InvStockLog.php b/app/common/model/InvStockLog.php new file mode 100644 index 0000000..7c580df --- /dev/null +++ b/app/common/model/InvStockLog.php @@ -0,0 +1,81 @@ + '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'); + } +} diff --git a/app/common/service/InvItemService.php b/app/common/service/InvItemService.php index 5a4b8f1..6ce0453 100644 --- a/app/common/service/InvItemService.php +++ b/app/common/service/InvItemService.php @@ -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()); + } } /** diff --git a/app/common/service/InvRequisitionItemReturnService.php b/app/common/service/InvRequisitionItemReturnService.php index 46f7787..22fe5ed 100644 --- a/app/common/service/InvRequisitionItemReturnService.php +++ b/app/common/service/InvRequisitionItemReturnService.php @@ -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) { diff --git a/app/common/service/InvRequisitionService.php b/app/common/service/InvRequisitionService.php index 3f3ed21..8a54519 100644 --- a/app/common/service/InvRequisitionService.php +++ b/app/common/service/InvRequisitionService.php @@ -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(), + ]; + } } \ No newline at end of file diff --git a/app/common/service/InvStockLogService.php b/app/common/service/InvStockLogService.php new file mode 100644 index 0000000..c2ab23c --- /dev/null +++ b/app/common/service/InvStockLogService.php @@ -0,0 +1,118 @@ + $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(), + ]; + } +} diff --git a/app/common/service/QrCodeService.php b/app/common/service/QrCodeService.php new file mode 100644 index 0000000..bcb270a --- /dev/null +++ b/app/common/service/QrCodeService.php @@ -0,0 +1,186 @@ +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; + } + } +} diff --git a/app/common/validate/InvItemValidate.php b/app/common/validate/InvItemValidate.php index 14ebf6d..da3cf2b 100644 --- a/app/common/validate/InvItemValidate.php +++ b/app/common/validate/InvItemValidate.php @@ -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', + ], ]; -} \ No newline at end of file +} diff --git a/app/common/validate/InvRequisitionValidate.php b/app/common/validate/InvRequisitionValidate.php index 584020d..a8ca885 100644 --- a/app/common/validate/InvRequisitionValidate.php +++ b/app/common/validate/InvRequisitionValidate.php @@ -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', + ], ]; } \ No newline at end of file diff --git a/app/mobile/controller/InvRequisitionController.php b/app/mobile/controller/InvRequisitionController.php index 96cce6c..7ee69c5 100644 --- a/app/mobile/controller/InvRequisitionController.php +++ b/app/mobile/controller/InvRequisitionController.php @@ -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()); + } + } } \ No newline at end of file diff --git a/composer.json b/composer.json index d502105..610289b 100644 --- a/composer.json +++ b/composer.json @@ -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. " diff --git a/composer.lock b/composer.lock index ecd2fc5..825c6df 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "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", diff --git a/config/route.php b/config/route.php index 8695d3d..a0fa733 100644 --- a/config/route.php +++ b/config/route.php @@ -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']); }); }); }); \ No newline at end of file