validate = $validate; $this->productRecordValidate = $productRecordValidate; parent::__construct(); } #[OA\Get(path: '/produce/process', summary: '获取工序下拉', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function processOptions(Request $request): Response { $items = Process::query() ->where('status', 1) ->orderBy('sort') ->orderBy('id') ->get(); return $this->formatSelect($items); } #[OA\Get(path: '/produce/process-step', summary: '获取步骤下拉', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function stepOptions(Request $request): Response { $processId = (int) $request->input('process_id', 0); $query = ProcessStep::query()->where('status', 1); if ($processId > 0) { $query->where('process_id', $processId); } $items = $query->orderBy('sort')->orderBy('id')->get(); return $this->formatSelect($items); } #[OA\Get(path: '/produce/step-status', summary: '获取状态下拉', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function statusOptions(Request $request): Response { $stepId = (int) $request->input('step_id', 0); $query = StepStatus::query()->where('status', 1); if ($stepId > 0) { $query->where('step_id', $stepId); } $items = $query->orderByDesc('is_initial')->orderBy('sort')->orderBy('id')->get(); return $this->formatSelect($items); } #[OA\Get(path: '/produce/material/lookup', summary: '按商品编码查询物料', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function materialLookup(Request $request): Response { $productCode = trim((string) $request->input('product_code', '')); if ($productCode === '') { return Json::fail('商品编码不能为空'); } $material = Material::query() ->with('category:id,category_name') ->where('product_code', $productCode) ->first(); if (!$material) { return Json::fail('未找到对应物料'); } return Json::success('ok', [ 'id' => $material->id, 'category_id' => $material->category_id, 'category_name' => $material->category_name, 'product_code' => $material->product_code, 'product_name' => $material->product_name, ]); } #[OA\Get(path: '/produce/material/options', summary: '获取维修备件选项', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function materialOptions(Request $request): Response { $productCode = trim((string) $request->input('product_code', '')); $productName = trim((string) $request->input('product_name', '')); $categoryId = (int) $request->input('category_id', 0); $page = max(1, (int) $request->input('page', 1)); $limit = min(50, max(1, (int) $request->input('limit', 10))); $query = Material::query()->with('category:id,category_name'); $query->when($productCode !== '', static fn ($builder) => $builder->where('product_code', 'like', "%{$productCode}%")); $query->when($productName !== '', static fn ($builder) => $builder->where('product_name', 'like', "%{$productName}%")); $query->when($categoryId > 0, static fn ($builder) => $builder->where('category_id', $categoryId)); $usageCounts = $this->materialUsageCounts(); $materials = $query->get(['id', 'category_id', 'product_code', 'product_name']) ->sort(function (Material $left, Material $right) use ($usageCounts): int { $leftCount = $usageCounts[(string) $left->product_code] ?? 0; $rightCount = $usageCounts[(string) $right->product_code] ?? 0; return $leftCount === $rightCount ? strcmp((string) $left->product_code, (string) $right->product_code) : $rightCount <=> $leftCount; }); $total = $materials->count(); $items = $materials->forPage($page, $limit) ->map(static fn (Material $material): array => [ 'id' => (string) $material->id, 'category_id' => (string) $material->category_id, 'category_name' => (string) $material->category_name, 'product_code' => (string) $material->product_code, 'product_name' => (string) $material->product_name, ]) ->values() ->all(); return Json::success('获取成功', compact('items', 'total', 'page', 'limit')); } private function materialUsageCounts(): array { $counts = []; RepairRecord::query()->without('exception')->whereNotNull('parts')->get(['parts'])->each(function (RepairRecord $record) use (&$counts): void { foreach ((array) $record->parts as $part) { $code = trim((string) ($part['product_code'] ?? '')); if ($code === '') { continue; } $counts[$code] = ($counts[$code] ?? 0) + max(1, (int) ($part['quantity'] ?? 1)); } }); return $counts; } #[OA\Get(path: '/produce/scanapp/material-category', summary: '获取扫码端物料分类选项', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function scanAppMaterialCategory(Request $request): Response { $items = MaterialCategory::query() ->orderBy('category_name') ->orderBy('id') ->get(['id', 'category_name']) ->map(static fn (MaterialCategory $item): array => [ 'id' => (string) $item->id, 'category_name' => (string) $item->category_name, ]) ->values() ->all(); return Json::success('获取成功', $items); } #[OA\Get(path: '/produce/hardware-fault-description/options', summary: '按物料分类获取硬件故障描述', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function hardwareFaultDescriptionOptions(Request $request): Response { $categoryIds = array_values(array_unique(array_filter(array_map( 'intval', explode(',', (string) $request->input('category_ids', '')) )))); if ($categoryIds === []) { return Json::success('获取成功', []); } $items = HardwareFaultDescription::query() ->whereIn('category_id', $categoryIds) ->where('is_enabled', 1) ->orderByDesc('use_count') ->orderBy('id') ->get(['id', 'category_id', 'description', 'use_count']) ->map(static function (HardwareFaultDescription $item): array { return [ 'id' => (string) $item->id, 'category_id' => (string) $item->category_id, 'description' => (string) $item->description, 'use_count' => (int) $item->use_count, ]; }) ->values() ->all(); return Json::success('获取成功', $items); } #[OA\Get(path: '/produce/config/info', summary: '获取前台生产配置', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function configInfo(Request $request): Response { $groupCode = (string) $request->input('group_code', ''); if ($groupCode === '') { return Json::fail('配置组代码不能为空'); } $items = ProduceConfig::query() ->where('group_code', $groupCode) ->orderBy('sort') ->orderBy('id') ->get(); $data = []; foreach ($items as $item) { $data[$item->code] = $item->content; } return Json::success('获取成功', $data); } #[OA\Get(path: '/produce/config/data-entry', summary: '获取扫码输入页配置', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function dataEntryConfig(Request $request): Response { $processId = (string) $request->input('process_id', ''); $stepId = (string) $request->input('step_id', ''); $orderNo = trim((string) $request->input('order_no', '')); $processOrderNo = trim((string) $request->input('process_order_no', '')); $productCode = trim((string) $request->input('product_code', '')); $userCode = trim((string) $request->input('user_code', '')); $applyMode = trim((string) $request->input('apply_mode', 'order')); $ruleContext = [ 'mode' => $applyMode, 'order_no' => $orderNo, 'process_order_no' => $processOrderNo, 'product_code' => $productCode, 'user_code' => $userCode, ]; $basicSettings = $this->getConfigGroupData('basic_setting'); $stepRule = null; if ($stepId !== '') { $stepRule = ProcessStep::query()->with(['process'])->find($stepId); } $specificRules = SpecificRule::query() ->where('is_enabled', 1) ->orderBy('sort') ->orderBy('id') ->get() ->filter(function (SpecificRule $rule) use ($stepId, $ruleContext) { return $this->matchSpecificRule($rule, $stepId, $ruleContext); }) ->values() ->map(function (SpecificRule $rule) { return [ 'id' => $rule->id, 'rule_name' => $rule->rule_name, 'rule_pattern' => $rule->rule_pattern, 'rule_type' => $rule->rule_type, 'apply_mode' => (string) ($rule->apply_mode ?: 'order'), 'match_field' => (string) ($rule->match_field ?: 'order_no'), 'step_ids' => $rule->step_ids ?? [], 'sort' => $rule->sort, 'remark' => $rule->remark, ]; }); return Json::success('获取成功', [ 'basic_setting' => $basicSettings, 'step_rule' => $stepRule ? [ 'id' => $stepRule->id, 'process_id' => $stepRule->process_id, 'process_name' => $stepRule->process?->process_name ?? '', 'step_code' => $stepRule->step_code, 'step_name' => $stepRule->step_name, 'prerequisite_steps' => $stepRule->prerequisite_steps ?? [], 'prerequisite_mode' => $stepRule->prerequisite_mode, 'prerequisite_require_final' => (int) ($stepRule->prerequisite_require_final ?? 1), 'allow_repeat_scan' => (int) ($stepRule->allow_repeat_scan ?? 0), 'repeat_scan_statuses' => $stepRule->repeat_scan_statuses ?? [], ] : null, 'specific_rules' => $specificRules, 'current' => [ 'process_id' => $processId, 'step_id' => $stepId, 'order_no' => $orderNo, 'process_order_no' => $processOrderNo, 'product_code' => $productCode, 'user_code' => $userCode, 'apply_mode' => $applyMode, ], ]); } #[OA\Get(path: '/produce/scan-data', summary: '前台最近扫码记录', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function recentRecords(Request $request): Response { $page = max(1, (int) $request->input('page', 1)); $limit = max(1, min(50, (int) $request->input('limit', 10))); $query = ScanData::query()->with(['process', 'step', 'status']); $this->applyScanDataFilter($query, $request); $total = (clone $query)->count(); $items = $query->orderByDesc('scan_time')->orderByDesc('id') ->forPage($page, $limit) ->get(); return Json::success('获取成功', compact('items', 'total')); } #[OA\Get(path: '/produce/scan-data/statistics', summary: '前台扫码统计', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function statistics(Request $request): Response { $query = ScanData::query(); $this->applyScanDataFilter($query, $request); $todayTotal = (int) (clone $query)->sum('quantity'); $successTotal = (int) (clone $query)->where('scan_result', 1)->sum('quantity'); $failedTotal = (int) (clone $query)->where('scan_result', 0)->sum('quantity'); return Json::success('获取成功', [ 'today_total' => $todayTotal, 'success_total' => $successTotal, 'failed_total' => $failedTotal, ]); } #[OA\Get(path: '/produce/scan-data/user-code-stats', summary: '前台用户扫码统计', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function userCodeStats(Request $request): Response { $query = ScanData::query() ->selectRaw('user_code, operator, SUM(quantity) as count'); $this->applyScanDataFilter($query, $request); $items = $query->groupBy('user_code', 'operator') ->orderByDesc('count') ->limit(20) ->get(); return Json::success('获取成功', $items); } #[OA\Get(path: '/produce/product-record/recent', summary: '前台最近整机记录', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function productRecentRecords(Request $request): Response { $page = max(1, (int) $request->input('page', 1)); $limit = max(1, min(50, (int) $request->input('limit', 10))); $query = ProductRecord::query()->with(['process', 'step', 'status']); $this->applyProductRecordFilter($query, $request); $total = (clone $query)->count(); $items = $query->orderByDesc('created_at')->orderByDesc('id') ->forPage($page, $limit) ->get(); return Json::success('获取成功', compact('items', 'total')); } #[OA\Get(path: '/produce/product-record/statistics', summary: '前台整机统计', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function productStatistics(Request $request): Response { $query = ProductRecord::query(); $this->applyProductRecordFilter($query, $request); $todayTotal = (int) (clone $query)->sum('quantity'); $successTotal = (int) (clone $query)->where('scan_result', 1)->sum('quantity'); $failedTotal = (int) (clone $query)->where('scan_result', 0)->sum('quantity'); return Json::success('获取成功', [ 'today_total' => $todayTotal, 'success_total' => $successTotal, 'failed_total' => $failedTotal, ]); } #[OA\Get(path: '/produce/product-record/user-code-stats', summary: '前台整机用户统计', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function productUserCodeStats(Request $request): Response { $query = ProductRecord::query() ->selectRaw('user_code, operator, SUM(quantity) as count'); $this->applyProductRecordFilter($query, $request); $items = $query->groupBy('user_code', 'operator') ->orderByDesc('count') ->limit(20) ->get(); return Json::success('获取成功', $items); } #[OA\Get(path: '/produce/member-user/check', summary: '校验用户编码对应会员', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function checkMemberUser(Request $request): Response { $userCode = trim((string) $request->input('user_code', '')); if ($userCode === '') { return Json::fail('用户编码不能为空'); } $member = Member::query() ->where('username', $userCode) ->first(['id', 'username', 'nickname']); if (!$member) { return Json::fail('用户编码不存在'); } return Json::success('校验成功', [ 'id' => (int) $member->id, 'username' => (string) $member->username, 'nickname' => (string) ($member->nickname ?: $member->username), ]); } #[OA\Post(path: '/produce/scan-data/record', summary: '前台提交扫码记录', tags: ['produce-web'])] #[SimpleResponse(description: '录入成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function record(Request $request): Response { $data = $request->post(); if (!$this->validate->scene('store')->check($data)) { return Json::fail($this->validate->getError()); } $userCode = trim((string) ($data['user_code'] ?? '')); $member = Member::query() ->where('username', $userCode) ->first(); if (!$member) { return Json::fail('用户编码不存在'); } $processId = (string) ($data['process_id'] ?? ''); $stepId = (string) ($data['step_id'] ?? ''); $statusId = $data['status_id'] ?? null; if (empty($statusId)) { $statusId = StepStatus::query() ->where('step_id', $stepId) ->where('is_initial', 1) ->value('id'); } $entryMode = (string) ($data['entry_mode'] ?? 'order'); $prerequisiteError = $this->validatePrerequisiteSteps( $stepId, (string) $data['order_no'] ); if ($prerequisiteError !== null) { return Json::fail($prerequisiteError); } // 一单多机仅增加按数量重复录入的能力,仍须遵守原有步骤规则。 if ($entryMode !== 'multi_machine') { $duplicateError = $this->validateRepeatScan( $processId, $stepId, (string) $data['order_no'] ); if ($duplicateError !== null) { return Json::fail($duplicateError); } } $quantity = max(1, (int) ($data['quantity'] ?? 1)); $now = time(); $result = Db::transaction(function () use ($data, $quantity, $userCode, $member, $processId, $stepId, $statusId, $request, $now) { $quantityError = $this->validateOrderStepQuantity( (string) $data['order_no'], $processId, $stepId, $quantity, (int) $data['scan_result'] === 1 ); if ($quantityError !== null) { return ['error' => $quantityError]; } $scanData = ScanData::query()->create([ 'order_no' => (string) $data['order_no'], 'quantity' => $quantity, 'user_code' => $userCode, 'operator' => (string) ($member->nickname ?: $member->username), 'operator_id' => (int) $member->id, 'process_id' => $processId === '' ? null : $processId, 'step_id' => $stepId === '' ? null : $stepId, 'status_id' => $statusId ? (int) $statusId : null, 'scan_result' => (int) $data['scan_result'], 'scan_time' => $now, 'remark' => (string) ($data['remark'] ?? ''), ]); $this->handleNextStepTransitions($scanData, $request, $member, $now); return ['model' => $scanData]; }); if (isset($result['error'])) { return Json::fail($result['error']); } return Json::success('录入成功', ['id' => $result['model']->id]); } #[OA\Post(path: '/produce/product-record/record', summary: '前台提交整机记录', tags: ['produce-web'])] #[SimpleResponse(description: '录入成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function productRecord(Request $request): Response { $data = $request->post(); if (!$this->productRecordValidate?->scene('store')->check($data)) { return Json::fail($this->productRecordValidate?->getError() ?: '整机录入数据校验失败'); } $userCode = trim((string) ($data['user_code'] ?? '')); $member = Member::query() ->where('username', $userCode) ->first(); if (!$member) { return Json::fail('用户编码不存在'); } $processId = (string) ($data['process_id'] ?? ''); $stepId = (string) ($data['step_id'] ?? ''); $statusId = $data['status_id'] ?? null; if (empty($statusId) && $stepId !== '') { $statusId = StepStatus::query() ->where('step_id', $stepId) ->where('is_initial', 1) ->value('id'); } $model = Db::transaction(function () use ($data, $userCode, $member, $processId, $stepId, $statusId) { return ProductRecord::query()->create([ 'process_order_no' => trim((string) ($data['process_order_no'] ?? '')), 'product_code' => trim((string) ($data['product_code'] ?? '')), 'quantity' => max(1, (int) ($data['quantity'] ?? 1)), 'user_code' => $userCode, 'operator' => (string) ($member->nickname ?: $member->username), 'operator_id' => (int) $member->id, 'process_id' => $processId === '' ? null : $processId, 'step_id' => $stepId === '' ? null : $stepId, 'status_id' => $statusId ? (int) $statusId : null, 'scan_result' => (int) ($data['scan_result'] ?? 1), 'remark' => (string) ($data['remark'] ?? ''), ]); }); return Json::success('录入成功', ['id' => $model->id]); } #[OA\Get(path: '/produce/exception/settings', summary: '获取异常设置', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function exceptionSettings(Request $request): Response { $startAt = time() - 30 * 24 * 60 * 60; $issueFrequency = ExceptionRecord::query() ->where('reported_at', '>=', $startAt) ->whereNotNull('issue_id') ->selectRaw('issue_id, COUNT(*) AS frequency') ->groupBy('issue_id') ->get() ->mapWithKeys(static function (ExceptionRecord $record): array { return [(string) $record->issue_id => (int) $record->frequency]; }) ->all(); $items = ExceptionSetting::query() ->where('is_enabled', 1) ->orderBy('setting_type') ->orderBy('parent_id') ->orderBy('sort') ->orderBy('id') ->get(['id', 'parent_id', 'setting_type', 'code', 'name', 'sort']) ->map(static function (ExceptionSetting $item) use ($issueFrequency): array { return [ 'id' => (string) $item->id, 'parent_id' => $item->parent_id === null ? null : (string) $item->parent_id, 'setting_type' => $item->setting_type, 'code' => $item->code, 'name' => $item->name, 'sort' => (int) $item->sort, 'frequency' => $item->setting_type === 'issue' ? ($issueFrequency[(string) $item->id] ?? 0) : 0, ]; }) ->values() ->all(); return Json::success('获取成功', $items); } #[OA\Get(path: '/produce/exception/statistics', summary: '获取异常统计', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] public function exceptionStatistics(Request $request): Response { $query = ExceptionRecord::query(); $this->applyExceptionRecordFilter($query, $request); return Json::success('获取成功', [ 'pending' => (clone $query)->where('status', 0)->count(), 'review' => (clone $query)->where('status', 1)->count(), 'done' => (clone $query)->where('status', 2)->count(), ]); } #[OA\Get(path: '/produce/exception/recent', summary: '获取最近异常记录', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] public function recentExceptions(Request $request): Response { $limit = max(1, min(20, (int) $request->input('limit', 4))); $query = ExceptionRecord::query(); $this->applyExceptionRecordFilter($query, $request); $items = $query->orderByDesc('reported_at')->orderByDesc('id') ->limit($limit) ->get(); return Json::success('获取成功', $items); } #[OA\Get(path: '/produce/query/records', summary: '查询生产记录', tags: ['produce-web-query'])] #[DataResponse(description: '查询成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function queryRecords(Request $request): Response { $recordType = trim((string) $request->input('record_type', 'scan')); $orderNo = trim((string) $request->input('order_no', '')); $keyword = trim((string) $request->input('keyword', '')); $userCode = trim((string) $request->input('user_code', '')); $status = $request->input('status', ''); $scanResult = $request->input('scan_result', ''); $page = max(1, (int) $request->input('page', 1)); $limit = max(1, min(100, (int) $request->input('limit', 20))); $startAt = $this->parseQueryDate($request->input('start_date')); $endAt = $this->parseQueryDate($request->input('end_date'), true); if (!in_array($recordType, ['scan', 'product', 'exception', 'repair'], true)) { return Json::fail('查询类型不正确'); } $query = match ($recordType) { 'scan' => ScanData::query()->with(['process', 'step', 'status']), 'product' => ProductRecord::query()->with(['process', 'step', 'status']), 'exception' => ExceptionRecord::query()->with(['process', 'step']), 'repair' => RepairRecord::query()->with('exception'), }; if ($recordType === 'scan') { $query->when($orderNo !== '', fn ($builder) => $builder->where('order_no', $orderNo)); $query->when($userCode !== '', fn ($builder) => $builder->where('user_code', $userCode)); $query->when($status !== '' && $status !== null, fn ($builder) => $builder->where('status_id', (int) $status)); $query->when($scanResult !== '' && $scanResult !== null, fn ($builder) => $builder->where('scan_result', (int) $scanResult)); $query->when($keyword !== '', fn ($builder) => $builder->where(function ($nested) use ($keyword) { $nested->where('order_no', 'like', "%{$keyword}%") ->orWhere('user_code', 'like', "%{$keyword}%") ->orWhere('operator', 'like', "%{$keyword}%") ->orWhere('remark', 'like', "%{$keyword}%"); })); $query->when($startAt !== null, fn ($builder) => $builder->where('scan_time', '>=', $startAt)); $query->when($endAt !== null, fn ($builder) => $builder->where('scan_time', '<=', $endAt)); $query->orderByDesc('scan_time')->orderByDesc('id'); } elseif ($recordType === 'product') { $query->when($orderNo !== '', fn ($builder) => $builder->where('process_order_no', $orderNo)); $query->when($userCode !== '', fn ($builder) => $builder->where('user_code', $userCode)); $query->when($status !== '' && $status !== null, fn ($builder) => $builder->where('status_id', (int) $status)); $query->when($scanResult !== '' && $scanResult !== null, fn ($builder) => $builder->where('scan_result', (int) $scanResult)); $query->when($keyword !== '', fn ($builder) => $builder->where(function ($nested) use ($keyword) { $nested->where('process_order_no', 'like', "%{$keyword}%") ->orWhere('product_code', 'like', "%{$keyword}%") ->orWhere('user_code', 'like', "%{$keyword}%") ->orWhere('operator', 'like', "%{$keyword}%") ->orWhere('remark', 'like', "%{$keyword}%"); })); $query->when($startAt !== null, fn ($builder) => $builder->where('created_at', '>=', $startAt)); $query->when($endAt !== null, fn ($builder) => $builder->where('created_at', '<=', $endAt)); $query->orderByDesc('created_at')->orderByDesc('id'); } elseif ($recordType === 'exception') { $query->when($orderNo !== '', fn ($builder) => $builder->where('order_no', $orderNo)); $query->when($userCode !== '', fn ($builder) => $builder->where('user_code', $userCode)); $query->when($status !== '' && $status !== null, fn ($builder) => $builder->where('status', (int) $status)); $query->when($keyword !== '', fn ($builder) => $builder->where(function ($nested) use ($keyword) { $nested->where('order_no', 'like', "%{$keyword}%") ->orWhere('user_code', 'like', "%{$keyword}%") ->orWhere('reporter', 'like', "%{$keyword}%") ->orWhere('type_name', 'like', "%{$keyword}%") ->orWhere('reason_name', 'like', "%{$keyword}%") ->orWhere('issue_name', 'like', "%{$keyword}%") ->orWhere('remark', 'like', "%{$keyword}%"); })); $query->when($startAt !== null, fn ($builder) => $builder->where('reported_at', '>=', $startAt)); $query->when($endAt !== null, fn ($builder) => $builder->where('reported_at', '<=', $endAt)); $query->orderByDesc('reported_at')->orderByDesc('id'); } else { $query->when($orderNo !== '', fn ($builder) => $builder->where('order_no', $orderNo)); $query->when($keyword !== '', fn ($builder) => $builder->where(function ($nested) use ($keyword) { $nested->where('repair_no', 'like', "%{$keyword}%") ->orWhere('order_no', 'like', "%{$keyword}%") ->orWhere('product_code', 'like', "%{$keyword}%") ->orWhere('product_name', 'like', "%{$keyword}%") ->orWhere('operator', 'like', "%{$keyword}%") ->orWhere('remark', 'like', "%{$keyword}%"); })); $query->when($startAt !== null, fn ($builder) => $builder->where('repaired_at', '>=', $startAt)); $query->when($endAt !== null, fn ($builder) => $builder->where('repaired_at', '<=', $endAt)); $query->orderByDesc('repaired_at')->orderByDesc('id'); } $total = (clone $query)->count(); $items = $query->forPage($page, $limit)->get()->map(fn ($item): array => $this->formatQueryRecord($recordType, $item))->values()->all(); return Json::success('查询成功', compact('items', 'total', 'page', 'limit')); } #[OA\Get(path: '/produce/query/timeline', summary: '查询订单生产时间线', tags: ['produce-web-query'])] #[DataResponse(description: '查询成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function queryTimeline(Request $request): Response { $orderNo = trim((string) $request->input('order_no', '')); if ($orderNo === '') { return Json::fail('订单号不能为空'); } $timeline = collect(); ScanData::query()->with(['process', 'step', 'status'])->where('order_no', $orderNo)->get()->each(function (ScanData $item) use ($timeline): void { $timeline->push($this->formatTimelineRecord('scan', $item, (int) $item->scan_time)); }); ProductRecord::query()->with(['process', 'step', 'status'])->where('process_order_no', $orderNo)->get()->each(function (ProductRecord $item) use ($timeline): void { $timeline->push($this->formatTimelineRecord('product', $item, (int) $item->created_at)); }); ExceptionRecord::query()->with(['process', 'step'])->where('order_no', $orderNo)->get()->each(function (ExceptionRecord $item) use ($timeline): void { $timeline->push($this->formatTimelineRecord('exception', $item, (int) $item->reported_at)); }); RepairRecord::query()->with('exception')->where('order_no', $orderNo)->get()->each(function (RepairRecord $item) use ($timeline): void { $timeline->push($this->formatTimelineRecord('repair', $item, (int) $item->repaired_at ?: (int) $item->created_at)); }); $items = $timeline->sortBy(fn (array $item): array => [$item['event_at'], (string) $item['id']])->values()->all(); return Json::success('查询成功', [ 'order_no' => $orderNo, 'total' => count($items), 'items' => $items, ]); } #[OA\Get(path: '/produce/exception/order-check', summary: '校验异常提报单号', tags: ['produce-web'])] #[DataResponse(description: '校验成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function checkExceptionOrder(Request $request): Response { $orderNo = trim((string) $request->input('order_no', '')); if ($orderNo === '') { return Json::fail('单号或条码不能为空'); } $reference = $this->findExceptionOrderReference($orderNo); if ($reference === null) { return Json::fail('未找到对应订单或整机记录,不能提报异常'); } return Json::success('校验成功', $reference); } #[OA\Get(path: '/produce/repair-record/tasks', summary: '获取待维修任务列表', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] public function repairTasks(Request $request): Response { $limit = max(1, min(100, (int) $request->input('limit', 50))); $tasks = ExceptionRecord::query() ->where('status', 0) ->orderBy('reported_at') ->orderBy('id') ->limit($limit) ->get(); return Json::success('获取成功', $tasks->map(static fn (ExceptionRecord $task): array => [ 'id' => (string) $task->id, 'device_no' => (string) $task->order_no, 'type_name' => (string) $task->type_name, 'reason_name' => (string) $task->reason_name, 'issue_name' => (string) ($task->issue_name ?? ''), 'reporter' => (string) ($task->reporter ?: $task->user_code), 'user_code' => (string) $task->user_code, 'reported_date' => $task->reported_date, 'status' => (int) $task->status, 'status_name' => $task->status_name, ])->values()->all()); } #[OA\Get(path: '/produce/repair-record/statistics', summary: '获取维修统计', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] public function repairStatistics(Request $request): Response { $query = RepairRecord::query(); $todayStart = strtotime(date('Y-m-d 00:00:00')); $todayEnd = $todayStart + 86400; return Json::success('获取成功', [ 'pending' => ExceptionRecord::query()->where('status', 0)->count(), 'processing' => ExceptionRecord::query()->where('status', 1)->count(), 'completed_today' => (clone $query)->whereBetween('repaired_at', [$todayStart, $todayEnd - 1])->count(), ]); } #[OA\Get(path: '/produce/exception/repair-task', summary: '获取维修异常任务', tags: ['produce-web'])] #[DataResponse(description: '获取成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function repairTask(Request $request): Response { $deviceNo = trim((string) $request->input('device_no', '')); if ($deviceNo === '') { return Json::fail('设备编码或订单号不能为空'); } $task = ExceptionRecord::query() ->where('order_no', $deviceNo) ->whereIn('status', [0, 1]) ->orderByDesc('reported_at') ->orderByDesc('id') ->first(); if (!$task) { return Json::fail('未找到待维修的异常任务'); } return Json::success('获取成功', [ 'id' => (string) $task->id, 'device_no' => (string) $task->order_no, 'type_name' => (string) $task->type_name, 'reason_name' => (string) $task->reason_name, 'issue_name' => (string) ($task->issue_name ?? ''), 'reporter' => (string) ($task->reporter ?: $task->user_code), 'user_code' => (string) $task->user_code, 'reported_date' => $task->reported_date, 'status_name' => $task->status_name, ]); } #[OA\Post(path: '/produce/repair-record/record', summary: '提交维修记录', tags: ['produce-web'])] #[SimpleResponse(description: '提交成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function repairRecord(Request $request, RepairRecordService $repairRecordService): Response { $data = $request->post(); $exceptionId = trim((string) ($data['exception_id'] ?? '')); $userCode = trim((string) ($data['user_code'] ?? '')); $repairResult = trim((string) ($data['repair_result'] ?? '')); $categoryIds = array_values(array_unique(array_filter(array_map(static fn ($id): string => trim((string) $id), (array) ($data['category_ids'] ?? $data['category_id'] ?? [])), static fn (string $id): bool => $id !== ''))); $parts = $data['parts'] ?? []; $images = array_values(array_filter((array) ($data['images'] ?? []), static function ($image): bool { return is_string($image) && preg_match('/^https?:\/\//', $image) === 1; })); $operationFaultSteps = $data['operation_fault_steps'] ?? []; $responsibleOperators = $data['responsible_operators'] ?? []; if ($exceptionId === '') { return Json::fail('请先加载维修任务'); } if ($userCode === '') { return Json::fail('维修人工号不能为空'); } if (!in_array($repairResult, ['material_defect', 'operation_defect', 'assembly_mismatch'], true)) { return Json::fail('请选择有效的维修判定'); } if ($repairResult === 'assembly_mismatch' && !$categoryIds) { return Json::fail('请选择搭配错误分类'); } if (!is_array($parts)) { return Json::fail('更换备件格式不正确'); } if (!is_array($operationFaultSteps) || !is_array($responsibleOperators)) { return Json::fail('作业不良责任信息格式不正确'); } $member = Member::query()->where('username', $userCode)->first(); if (!$member) { return Json::fail('用户编码不存在'); } try { $record = Db::transaction(function () use ($exceptionId, $repairResult, $categoryIds, $parts, $images, $operationFaultSteps, $responsibleOperators, $data, $member, $repairRecordService) { $exception = ExceptionRecord::query() ->lockForUpdate() ->whereKey($exceptionId) ->whereIn('status', [0, 1]) ->first(); if (!$exception) { throw new \InvalidArgumentException('维修任务不存在或已处理'); } $partSnapshots = []; $operationFaultStepSnapshots = []; $responsibleOperatorSnapshots = []; $primaryFault = null; if ($repairResult === 'assembly_mismatch') { $selectedCategories = MaterialCategory::query()->whereIn('id', $categoryIds)->get(['id', 'category_name'])->keyBy('id'); if ($selectedCategories->count() !== count($categoryIds)) { throw new \InvalidArgumentException('搭配错误分类不存在或已删除'); } } if ($repairResult === 'operation_defect') { $scanIds = array_values(array_unique(array_filter(array_merge($operationFaultSteps, $responsibleOperators), static fn ($id): bool => (string) $id !== ''))); if (!$operationFaultSteps || !$responsibleOperators) { throw new \InvalidArgumentException('请选择作业不良责任步骤和责任人员'); } if (!$scanIds) { throw new \InvalidArgumentException('作业不良责任信息不能为空'); } $scanRecords = ScanData::query() ->with(['process', 'step']) ->where('order_no', $exception->order_no) ->whereIn('id', $scanIds) ->get() ->keyBy('id'); if ($scanRecords->count() !== count($scanIds)) { throw new \InvalidArgumentException('责任步骤或责任人员不属于当前维修订单'); } foreach (array_values(array_unique(array_filter($operationFaultSteps, static fn ($id): bool => (string) $id !== ''))) as $scanId) { $scan = $scanRecords->get($scanId); $operationFaultStepSnapshots[] = [ 'scan_id' => (string) $scan->id, 'process_id' => (string) $scan->process_id, 'process_name' => (string) $scan->process_name, 'step_id' => (string) $scan->step_id, 'step_name' => (string) $scan->step_name, ]; } foreach (array_values(array_unique(array_filter($responsibleOperators, static fn ($id): bool => (string) $id !== ''))) as $scanId) { $scan = $scanRecords->get($scanId); $responsibleOperatorSnapshots[] = [ 'scan_id' => (string) $scan->id, 'operator_id' => (string) ($scan->operator_id ?? ''), 'operator' => (string) $scan->operator, 'user_code' => (string) $scan->user_code, 'process_id' => (string) $scan->process_id, 'process_name' => (string) $scan->process_name, 'step_id' => (string) $scan->step_id, 'step_name' => (string) $scan->step_name, ]; } } foreach ($parts as $part) { if (!is_array($part)) { throw new \InvalidArgumentException('更换备件格式不正确'); } $productCode = trim((string) ($part['product_code'] ?? $part['code'] ?? '')); if ($productCode === '') { throw new \InvalidArgumentException('更换备件商品编码不能为空'); } $material = Material::query()->where('product_code', $productCode)->first(); if (!$material) { throw new \InvalidArgumentException(sprintf('备件商品编码不存在:%s', $productCode)); } $source = trim((string) ($part['source'] ?? 'new')); if (!in_array($source, ['new', 'after_sales', 'refurbished'], true)) { throw new \InvalidArgumentException('备件来源不正确'); } $quantity = (int) ($part['quantity'] ?? 1); if ($quantity < 1 || $quantity > 999) { throw new \InvalidArgumentException('备件数量应在 1 到 999 之间'); } $faultIds = array_values(array_unique(array_filter((array) ($part['fault_description_ids'] ?? $part['selected_fault_point_ids'] ?? $part['selectedFaultPointIds'] ?? []), static fn ($id): bool => (string) $id !== ''))); $faults = collect(); if ($faultIds) { $faults = HardwareFaultDescription::query() ->whereIn('id', $faultIds) ->where('category_id', $material->category_id) ->get(['id', 'category_id', 'description']) ->keyBy('id'); if ($faults->count() !== count($faultIds)) { throw new \InvalidArgumentException('备件故障点不存在或与物料分类不匹配'); } $primaryFault ??= $faults->first(); } $partSnapshots[] = [ 'product_code' => (string) $material->product_code, 'product_name' => (string) $material->product_name, 'category_id' => (string) $material->category_id, 'category_name' => (string) $material->category_name, 'source' => $source, 'quantity' => $quantity, 'fault_descriptions' => $faults->map(static fn (HardwareFaultDescription $fault): array => [ 'id' => (string) $fault->id, 'description' => (string) $fault->description, ])->values()->all(), ]; } $now = time(); $datePrefix = date('Ymd', $now); $sequence = RepairRecord::query() ->withTrashed() ->where('repair_no', 'like', sprintf('RP%s%%', $datePrefix)) ->lockForUpdate() ->count() + 1; if ($sequence > 9999) { throw new \RuntimeException('当天维修单号已超过9999条,无法生成新的维修单号'); } $repairNo = sprintf('RP%s%04d', $datePrefix, $sequence); $repair = $repairRecordService->save([ 'repair_no' => $repairNo, 'exception_id' => (string) $exception->id, 'exception_reason' => (string) $exception->reason_name, 'exception_issue' => (string) ($exception->issue_name ?? ''), 'order_no' => (string) $exception->order_no, 'category_id' => $repairResult === 'assembly_mismatch' ? $categoryIds[0] : $primaryFault?->category_id, 'category_ids' => $repairResult === 'assembly_mismatch' ? $categoryIds : null, 'fault_description_id' => $primaryFault?->id, 'repair_result' => $repairResult, 'parts' => $partSnapshots, 'images' => $images, 'operation_fault_steps' => $operationFaultStepSnapshots, 'responsible_operators' => $responsibleOperatorSnapshots, 'operator' => (string) ($member->nickname ?: $member->username), 'repaired_at' => $now, 'remark' => trim((string) ($data['remark'] ?? '')), ]); $exception->fill([ 'status' => 2, 'handler' => (string) ($member->nickname ?: $member->username), 'handler_id' => $member->id, 'handled_at' => $now, ])->save(); return $repair; }); } catch (\Throwable $e) { return Json::fail($e->getMessage()); } return Json::success('提交成功', ['id' => (string) $record->id, 'repair_no' => $record->repair_no]); } #[OA\Post(path: '/produce/exception/record', summary: '提交异常记录', tags: ['produce-web'])] #[SimpleResponse(description: '提交成功')] #[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')] public function exceptionRecord(Request $request): Response { $data = $request->post(); $orderNo = trim((string) ($data['order_no'] ?? '')); $userCode = trim((string) ($data['user_code'] ?? '')); $typeId = (string) ($data['type_id'] ?? ''); $reasonId = (string) ($data['reason_id'] ?? ''); $issueId = (string) ($data['issue_id'] ?? ''); if ($orderNo === '') { return Json::fail('单号或条码不能为空'); } if ($userCode === '') { return Json::fail('提报人工号不能为空'); } if ($this->findExceptionOrderReference($orderNo) === null) { return Json::fail('未找到对应订单或整机记录,不能提报异常'); } $member = Member::query()->where('username', $userCode)->first(); if (!$member) { return Json::fail('用户编码不存在'); } $config = $this->getConfigGroupData('basic_setting'); $processId = (int) ($config['exception_process_id'] ?? 0); $stepId = (int) ($config['exception_step_id'] ?? 0); if ($processId <= 0 || $stepId <= 0) { return Json::fail('异常提报工序步骤尚未配置,请联系管理员'); } $step = ProcessStep::query() ->whereKey($stepId) ->where('process_id', $processId) ->where('status', 1) ->first(); if (!$step) { return Json::fail('异常提报工序步骤配置无效,请联系管理员'); } $type = ExceptionSetting::query() ->whereKey($typeId) ->where('setting_type', 'type') ->where('is_enabled', 1) ->first(); if (!$type) { return Json::fail('请选择有效的处理类型'); } $reason = ExceptionSetting::query() ->whereKey($reasonId) ->where('parent_id', $type->id) ->where('setting_type', 'reason') ->where('is_enabled', 1) ->first(); if (!$reason) { return Json::fail('请选择有效的异常原因'); } $issue = null; if ($issueId !== '') { $issue = ExceptionSetting::query() ->whereKey($issueId) ->where('parent_id', $reason->id) ->where('setting_type', 'issue') ->where('is_enabled', 1) ->first(); if (!$issue) { return Json::fail('具体问题与异常原因不匹配'); } } $images = array_values(array_filter((array) ($data['images'] ?? []), static function ($image): bool { return is_string($image) && preg_match('/^https?:\/\//', $image) === 1; })); $record = ExceptionRecord::query()->create([ 'order_no' => $orderNo, 'process_id' => $processId, 'step_id' => $stepId, 'user_code' => $userCode, 'reporter' => (string) ($member->nickname ?: $member->username), 'reporter_id' => (int) $member->id, 'type_id' => $type->id, 'type_code' => $type->code, 'type_name' => $type->name, 'reason_id' => $reason->id, 'reason_code' => $reason->code, 'reason_name' => $reason->name, 'issue_id' => $issue?->id, 'issue_code' => $issue?->code, 'issue_name' => $issue?->name, 'images' => $images, 'remark' => trim((string) ($data['remark'] ?? '')), 'status' => 0, 'reported_at' => time(), ]); return Json::success('提交成功', ['id' => $record->id]); } /** * 查找可用于异常提报的订单或整机记录。 */ private function findExceptionOrderReference(string $orderNo): ?array { $scanData = ScanData::query() ->where('order_no', $orderNo) ->orderByDesc('scan_time') ->first(['id', 'order_no', 'process_id', 'step_id', 'scan_time']); if ($scanData) { return [ 'reference_type' => 'order', 'reference_name' => '订单记录', 'reference_id' => (string) $scanData->id, 'order_no' => (string) $scanData->order_no, 'process_id' => $scanData->process_id === null ? null : (string) $scanData->process_id, 'step_id' => $scanData->step_id === null ? null : (string) $scanData->step_id, ]; } $productRecord = ProductRecord::query() ->where(function ($query) use ($orderNo) { $query->where('process_order_no', $orderNo) ->orWhere('product_code', $orderNo); }) ->orderByDesc('created_at') ->first(['id', 'process_order_no', 'product_code', 'process_id', 'step_id']); if ($productRecord) { return [ 'reference_type' => 'product', 'reference_name' => '整机记录', 'reference_id' => (string) $productRecord->id, 'order_no' => (string) ($productRecord->process_order_no ?: $productRecord->product_code), 'process_id' => $productRecord->process_id === null ? null : (string) $productRecord->process_id, 'step_id' => $productRecord->step_id === null ? null : (string) $productRecord->step_id, ]; } return null; } private function parseQueryDate(mixed $value, bool $endOfDay = false): ?int { $date = trim((string) $value); if ($date === '') { return null; } try { $parsed = new \DateTimeImmutable($date); if ($endOfDay && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1) { $parsed = $parsed->setTime(23, 59, 59); } return $parsed->getTimestamp(); } catch (\Throwable) { return null; } } private function formatQueryRecord(string $recordType, object $item): array { return match ($recordType) { 'scan' => [ 'id' => (string) $item->id, 'record_type' => 'scan', 'record_type_name' => '扫码记录', 'order_no' => (string) $item->order_no, 'product_code' => '', 'process_name' => (string) $item->process_name, 'step_name' => (string) $item->step_name, 'status_name' => (string) $item->status_name, 'result_name' => (int) $item->scan_result === 1 ? '成功' : '失败', 'operator' => (string) $item->operator, 'user_code' => (string) $item->user_code, 'quantity' => (int) $item->quantity, 'event_at' => (int) $item->scan_time, 'event_date' => $item->scan_date, 'remark' => (string) $item->remark, ], 'product' => [ 'id' => (string) $item->id, 'record_type' => 'product', 'record_type_name' => '整机记录', 'order_no' => (string) $item->process_order_no, 'product_code' => (string) $item->product_code, 'process_name' => (string) $item->process_name, 'step_name' => (string) $item->step_name, 'status_name' => (string) $item->status_name, 'result_name' => (int) $item->scan_result === 1 ? '成功' : '失败', 'operator' => (string) $item->operator, 'user_code' => (string) $item->user_code, 'quantity' => (int) $item->quantity, 'event_at' => (int) $item->created_at, 'event_date' => $item->created_date, 'remark' => (string) $item->remark, ], 'exception' => [ 'id' => (string) $item->id, 'record_type' => 'exception', 'record_type_name' => '异常提报', 'order_no' => (string) $item->order_no, 'product_code' => '', 'process_name' => (string) $item->process_name, 'step_name' => (string) $item->step_name, 'status_name' => (string) $item->status_name, 'result_name' => (string) $item->type_name, 'operator' => (string) $item->reporter, 'user_code' => (string) $item->user_code, 'quantity' => 1, 'exception_reason' => (string) $item->reason_name, 'exception_content' => (string) $item->issue_name, 'event_at' => (int) $item->reported_at, 'event_date' => $item->reported_date, 'remark' => (string) $item->remark, ], default => [ 'id' => (string) $item->id, 'record_type' => 'repair', 'record_type_name' => '维修记录', 'order_no' => (string) $item->order_no, 'product_code' => (string) $item->product_code, 'process_name' => $this->getRepairFlowNodeNames()['process_name'], 'step_name' => $this->getRepairFlowNodeNames()['step_name'], 'status_name' => '已维修', 'result_name' => (string) $item->repair_result_name, 'operator' => (string) $item->operator, 'user_code' => (string) ($item->exception?->user_code ?? ''), 'quantity' => 1, 'material_category' => (string) ($item->category_name ?: $item->category_names), 'fault_description' => (string) ($item->fault_description_name ?: $item->fault_description), 'event_at' => (int) ($item->repaired_at ?: $item->created_at), 'event_date' => $item->repaired_date ?: $item->created_date, 'remark' => (string) $item->remark, ], }; } /** 获取维修记录配置的工序与步骤名称。 */ private function getRepairFlowNodeNames(): array { if ($this->repairFlowCache !== null) { return $this->repairFlowCache; } $basic = $this->getConfigGroupData('basic_setting'); $flow = $this->getConfigGroupData('repair_flow'); $processId = (int) ($basic['repair_process_id'] ?? 0); $stepId = (int) ($basic['repair_step_id'] ?? 0); $processId = $processId > 0 ? $processId : (int) ($flow['repair_process_id'] ?? 0); $stepId = $stepId > 0 ? $stepId : (int) ($flow['repair_step_id'] ?? 0); $process = $processId > 0 ? Process::query()->withTrashed()->find($processId) : null; $step = $stepId > 0 ? ProcessStep::query()->withTrashed()->find($stepId) : null; return $this->repairFlowCache = [ 'process_name' => (string) ($process?->process_name ?? ''), 'step_name' => (string) ($step?->step_name ?? ''), ]; } private function formatTimelineRecord(string $recordType, object $item, int $eventAt): array { $record = $this->formatQueryRecord($recordType, $item); $record['event_at'] = $eventAt; $record['event_date'] = $eventAt > 0 ? date('Y-m-d H:i:s', $eventAt) : null; return $record; } protected function applyExceptionRecordFilter($query, Request $request): void { $userCode = trim((string) $request->input('user_code', '')); if ($userCode !== '') { $query->where('user_code', $userCode); } } protected function applyScanDataFilter($query, Request $request): void { $orderNo = trim((string) $request->input('order_no', '')); $userCode = trim((string) $request->input('user_code', '')); $processId = (int) $request->input('process_id', 0); $stepId = (int) $request->input('step_id', 0); $statusId = (int) $request->input('status_id', 0); $scanResult = $request->input('scan_result', ''); if ($orderNo !== '') { $query->where('order_no', $orderNo); } if ($userCode !== '') { $query->where('user_code', $userCode); } if ($processId > 0) { $query->where('process_id', $processId); } if ($stepId > 0) { $query->where('step_id', $stepId); } if ($statusId > 0) { $query->where('status_id', $statusId); } if ($scanResult !== '' && $scanResult !== null) { $query->where('scan_result', (int) $scanResult); } $recentOnly = (int) ProduceConfig::query() ->where('group_code', 'basic_setting') ->where('code', 'recent_records_today_only') ->value('content'); if ($recentOnly === 1 && $orderNo === '') { $start = strtotime(date('Y-m-d 00:00:00')); $end = strtotime(date('Y-m-d 23:59:59')); $query->whereBetween('scan_time', [$start, $end]); } } protected function applyProductRecordFilter($query, Request $request): void { $processId = (int) $request->input('process_id', 0); $stepId = (int) $request->input('step_id', 0); $statusId = (int) $request->input('status_id', 0); $scanResult = $request->input('scan_result', ''); if ($processId > 0) { $query->where('process_id', $processId); } if ($stepId > 0) { $query->where('step_id', $stepId); } if ($statusId > 0) { $query->where('status_id', $statusId); } if ($scanResult !== '' && $scanResult !== null) { $query->where('scan_result', (int) $scanResult); } $recentOnly = (int) ProduceConfig::query() ->where('group_code', 'basic_setting') ->where('code', 'recent_records_today_only') ->value('content'); if ($recentOnly === 1) { $start = strtotime(date('Y-m-d 00:00:00')); $end = strtotime(date('Y-m-d 23:59:59')); $query->whereBetween('created_at', [$start, $end]); } } protected function getConfigGroupData(string $groupCode): array { $items = ProduceConfig::query() ->where('group_code', $groupCode) ->orderBy('sort') ->orderBy('id') ->get(); $data = []; foreach ($items as $item) { $data[$item->code] = $item->content; } return $data; } protected function handleNextStepTransitions(ScanData $currentScanData, Request $request, Member $member, int $now): void { $previousRelations = \plugin\produce\app\model\StepRelation::query() ->where('to_step_id', (string) $currentScanData->step_id) ->where('status', 1) ->orderBy('priority', 'asc') ->orderBy('sort', 'asc') ->orderBy('id', 'asc') ->get(); foreach ($previousRelations as $relation) { $previousScanData = ScanData::query() ->where('order_no', (string) $currentScanData->order_no) ->where('step_id', (string) $relation->from_step_id) ->orderByDesc('scan_time') ->orderByDesc('id') ->first(); if (!$previousScanData || empty($previousScanData->status_id)) { continue; } $transition = StatusTransition::query() ->where('step_id', (string) $relation->from_step_id) ->where('from_status_id', (string) $previousScanData->status_id) ->where('trigger_event', 'next_step_scan') ->where('auto_trigger', 1) ->where('status', 1) ->orderBy('priority', 'desc') ->orderBy('sort', 'asc') ->orderBy('id', 'asc') ->first(); if (!$transition) { continue; } $startedAt = microtime(true); $fromStatusId = (string) $previousScanData->status_id; $toStatusId = (string) $transition->to_status_id; $isSuccess = 1; $errorMessage = ''; try { $previousScanData->status_id = $transition->to_status_id; $previousScanData->save(); } catch (\Throwable $throwable) { $isSuccess = 0; $errorMessage = $throwable->getMessage(); } TransitionLog::query()->create([ 'id' => Snowflake::generate(), 'work_order_id' => $previousScanData->id, 'work_order_no' => (string) $currentScanData->order_no, 'step_id' => (string) $previousScanData->step_id, 'transition_id' => $transition->id, 'from_status_id' => $fromStatusId === '' ? null : $fromStatusId, 'to_status_id' => $toStatusId === '' ? null : $toStatusId, 'trigger_event' => 'next_step_scan', 'trigger_data' => [ 'order_no' => (string) $currentScanData->order_no, 'user_code' => (string) $currentScanData->user_code, 'operator' => (string) $currentScanData->operator, 'operator_id' => (int) ($currentScanData->operator_id ?? 0), 'process_id' => (string) ($currentScanData->process_id ?? ''), 'current_scan_data_id' => $currentScanData->id, 'current_step_id' => (string) $currentScanData->step_id, 'current_status_id' => (string) ($currentScanData->status_id ?? ''), 'scan_result' => (int) ($currentScanData->scan_result ?? 0), 'remark' => (string) ($currentScanData->remark ?? ''), 'previous_scan_data_id' => $previousScanData->id, 'previous_step_id' => (string) $previousScanData->step_id, 'previous_status_id' => $fromStatusId, 'relation_id' => $relation->id, 'relation_type' => $relation->relation_type, ], 'trigger_user' => (string) ($member->nickname ?: $member->username), 'trigger_ip' => $request->getRealIp(), 'is_auto' => 1, 'is_success' => $isSuccess, 'error_message' => $errorMessage !== '' ? $errorMessage : null, 'duration' => (int) round((microtime(true) - $startedAt) * 1000), 'remark' => '下一工序扫码自动触发状态转换', 'created_by' => (int) $member->id, 'created_at' => $now, ]); } } /** * 订单首笔成功记录所在的工序步骤作为数量来源步骤:该步骤可以持续累加; * 后续每个步骤的成功累计数量不得超过来源步骤的成功累计数量。 * * @param bool $isSuccess 失败记录不计入步骤累计数量 */ protected function validateOrderStepQuantity( string $orderNo, string $processId, string $stepId, int $quantity, bool $isSuccess ): ?string { if (!$isSuccess) { return null; } // 在事务内锁定该订单既有成功记录,避免并发提交时基于旧累计数量重复放行。 $records = ScanData::query() ->where('order_no', $orderNo) ->where('scan_result', 1) ->orderBy('scan_time') ->orderBy('id') ->lockForUpdate() ->get(['id', 'quantity', 'process_id', 'step_id']); // 首笔成功记录建立数量来源步骤,本次可直接写入。 if ($records->isEmpty()) { return null; } $sourceRecord = $records->first(); $sourceProcessId = (string) $sourceRecord->process_id; $sourceStepId = (string) $sourceRecord->step_id; // 数量来源步骤允许多人、多次持续累加,以其最新累计数量作为后续步骤的上限。 if ($processId === $sourceProcessId && $stepId === $sourceStepId) { return null; } $sourceQuantity = (int) $records ->filter(static function (ScanData $record) use ($sourceProcessId, $sourceStepId) { return (string) $record->process_id === $sourceProcessId && (string) $record->step_id === $sourceStepId; }) ->sum('quantity'); $completedQuantity = (int) $records ->filter(static function (ScanData $record) use ($processId, $stepId) { return (string) $record->process_id === $processId && (string) $record->step_id === $stepId; }) ->sum('quantity'); if ($completedQuantity > $sourceQuantity) { return sprintf( '数量来源步骤累计为 %d,当前步骤历史累计已达 %d,存在超报数据,请先由管理员更正后再继续录入', $sourceQuantity, $completedQuantity ); } if ($completedQuantity + $quantity > $sourceQuantity) { return sprintf( '数量来源步骤累计为 %d,当前步骤已累计 %d,本次最多可录入 %d', $sourceQuantity, $completedQuantity, $sourceQuantity - $completedQuantity ); } return null; } protected function validatePrerequisiteSteps(string $stepId, string $orderNo): ?string { $stepRule = ProcessStep::query() ->whereKey((string) $stepId) ->first(); if (!$stepRule) { return null; } $prerequisiteStepIds = is_array($stepRule->prerequisite_steps) ? $stepRule->prerequisite_steps : []; $prerequisiteMode = (string) ($stepRule->prerequisite_mode ?: 'all'); $requireFinal = (int) ($stepRule->prerequisite_require_final ?? 1) === 1; if ($prerequisiteMode === 'none' || $prerequisiteStepIds === []) { return null; } $prerequisiteSteps = ProcessStep::query() ->whereIn('id', $prerequisiteStepIds) ->get(['id', 'step_name']) ->keyBy('id'); $matchedSteps = []; $unmatchedNames = []; foreach ($prerequisiteStepIds as $prerequisiteStepId) { $matched = $this->hasSatisfiedPrerequisiteStep((string) $prerequisiteStepId, $orderNo, $requireFinal); if ($matched) { $matchedSteps[] = (string) $prerequisiteStepId; continue; } $unmatchedNames[] = (string) ($prerequisiteSteps->get($prerequisiteStepId)?->step_name ?? ('步骤ID:' . $prerequisiteStepId)); } if ($prerequisiteMode === 'all' && $unmatchedNames !== []) { return '前置步骤未完成:' . implode('、', $unmatchedNames); } if ($prerequisiteMode === 'any' && $matchedSteps === []) { return '前置步骤未满足,至少需要完成一个前置步骤'; } return null; } protected function hasSatisfiedPrerequisiteStep(string $stepId, string $orderNo, bool $requireFinal): bool { $query = ScanData::query() ->where('order_no', $orderNo) ->where('step_id', $stepId) ->orderByDesc('scan_time') ->orderByDesc('id'); if (!$requireFinal) { return $query->exists(); } return $query->whereHas('status', function ($builder) { $builder->where('is_final', 1); })->exists(); } protected function validateRepeatScan(string $processId, string $stepId, string $orderNo): ?string { $latestRecord = ScanData::query() ->where('order_no', $orderNo) ->where('process_id', $processId) ->where('step_id', $stepId) ->orderByDesc('scan_time') ->orderByDesc('id') ->first(); if (!$latestRecord) { return null; } $repeatPolicy = $this->resolveRepeatPolicy($stepId, $orderNo); if (!$repeatPolicy['allow_repeat']) { return '当前配置不允许重复扫码'; } $allowedStatuses = array_values(array_filter(array_map('strval', is_array($repeatPolicy['repeat_scan_statuses']) ? $repeatPolicy['repeat_scan_statuses'] : []), static fn ($value) => $value !== '')); $currentStatusId = (string) ($latestRecord->status_id ?? ''); if ($allowedStatuses !== [] && ($currentStatusId === '' || !in_array($currentStatusId, $allowedStatuses, true))) { return '当前状态不允许重复扫码'; } $interval = max(0, (int) $repeatPolicy['repeat_scan_interval']); if ($interval > 0 && (time() - (int) $latestRecord->scan_time) < $interval) { return '重复扫码间隔不能小于 ' . $interval . ' 秒'; } return null; } protected function resolveRepeatPolicy(string $stepId, string $orderNo): array { $basicSettings = $this->getConfigGroupData('basic_setting'); $policy = [ 'allow_repeat' => (int) ($basicSettings['global_allow_repeat'] ?? 0) === 1, 'repeat_scan_interval' => max(0, (int) ($basicSettings['repeat_scan_interval'] ?? 0)), 'repeat_scan_statuses' => [], ]; $stepRule = ProcessStep::query() ->whereKey((string) $stepId) ->first(); if ($stepRule) { $policy['allow_repeat'] = (int) ($stepRule->allow_repeat_scan ?? 0) === 1; $policy['repeat_scan_statuses'] = is_array($stepRule->repeat_scan_statuses) ? $stepRule->repeat_scan_statuses : []; } $specificRule = SpecificRule::query() ->where('is_enabled', 1) ->orderBy('sort') ->orderBy('id') ->get() ->first(function (SpecificRule $rule) use ($stepId, $orderNo) { return $this->matchSpecificRule($rule, $stepId, [ 'mode' => 'order', 'order_no' => $orderNo, ]); }); if ($specificRule) { $policy['allow_repeat'] = (int) $specificRule->rule_type === 1; } return $policy; } protected function matchSpecificRule(SpecificRule $rule, string $stepId, array|string $context): bool { $ruleStepIds = $rule->step_ids ?? []; $stepMatched = empty($ruleStepIds) || in_array((string) $stepId, array_map('strval', $ruleStepIds), true); if (!$stepMatched) { return false; } $normalizedContext = is_array($context) ? $context : [ 'mode' => 'order', 'order_no' => (string) $context, ]; $ruleMode = (string) ($rule->apply_mode ?: 'order'); $currentMode = (string) ($normalizedContext['mode'] ?? 'order'); if ($ruleMode !== 'both' && $ruleMode !== $currentMode) { return false; } $matchField = (string) ($rule->match_field ?: 'order_no'); $matchValue = trim((string) ($normalizedContext[$matchField] ?? '')); $pattern = (string) $rule->rule_pattern; if ($pattern === '' || $matchValue === '') { return false; } return @preg_match('/' . str_replace('/', '\\/', $pattern) . '/', $matchValue) === 1; } protected function initialize(): void { } }