Files
MrleeAdmin/backend/plugin/produce/app/api/controller/DataEntryController.php
T

1812 lines
80 KiB
PHP
Raw Normal View History

2026-08-31 13:16:46 +08:00
<?php
declare(strict_types=1);
namespace plugin\produce\app\api\controller;
use app\api\CurrentMember;
use app\api\controller\Base;
use app\model\member\Member;
use core\base\BaseValidate;
use core\tool\Json;
use core\uuid\Snowflake;
use madong\swagger\annotation\response\DataResponse;
use madong\swagger\annotation\response\SimpleResponse;
use madong\swagger\attribute\AllowAnonymous;
use OpenApi\Attributes as OA;
use plugin\produce\app\model\ExceptionRecord;
use plugin\produce\app\model\ExceptionSetting;
use plugin\produce\app\model\HardwareFaultDescription;
use plugin\produce\app\model\Material;
use plugin\produce\app\model\MaterialCategory;
use plugin\produce\app\model\Process;
use plugin\produce\app\model\ProcessStep;
use plugin\produce\app\model\ProductRecord;
use plugin\produce\app\model\ProduceConfig;
use plugin\produce\app\model\RepairRecord;
use plugin\produce\app\model\ScanData;
use plugin\produce\app\service\admin\RepairRecordService;
use plugin\produce\app\model\StatusTransition;
use plugin\produce\app\model\SpecificRule;
use plugin\produce\app\model\StepStatus;
use plugin\produce\app\model\TransitionLog;
use plugin\produce\app\validate\ProductRecordValidate;
use plugin\produce\app\validate\ScanDataValidate;
use support\Request;
use support\Response;
use support\Db;
final class DataEntryController extends Base
{
protected ?BaseValidate $validate = null;
protected ?BaseValidate $productRecordValidate = null;
private ?array $repairFlowCache = null;
2026-08-31 13:16:46 +08:00
public function __construct(
protected CurrentMember $currentMember,
ScanDataValidate $validate,
ProductRecordValidate $productRecordValidate
) {
$this->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;
}
2026-08-31 13:16:46 +08:00
#[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: '查询成功')]
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', ''));
2026-09-03 19:56:19 +08:00
$processId = (int) $request->input('process_id', 0);
$stepId = (int) $request->input('step_id', 0);
2026-08-31 13:16:46 +08:00
$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']),
2026-08-31 13:16:46 +08:00
'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));
2026-09-03 19:56:19 +08:00
$query->when($processId > 0, fn ($builder) => $builder->where('process_id', $processId));
$query->when($stepId > 0, fn ($builder) => $builder->where('step_id', $stepId));
2026-08-31 13:16:46 +08:00
$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));
2026-09-03 19:56:19 +08:00
$query->when($processId > 0, fn ($builder) => $builder->where('process_id', $processId));
$query->when($stepId > 0, fn ($builder) => $builder->where('step_id', $stepId));
2026-08-31 13:16:46 +08:00
$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));
2026-09-03 19:56:19 +08:00
$query->when($processId > 0, fn ($builder) => $builder->where('process_id', $processId));
$query->when($stepId > 0, fn ($builder) => $builder->where('step_id', $stepId));
2026-08-31 13:16:46 +08:00
$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));
2026-09-03 19:56:19 +08:00
$query->when($processId > 0, fn ($builder) => $builder->where('process_id', $processId));
$query->when($stepId > 0, fn ($builder) => $builder->where('step_id', $stepId));
2026-08-31 13:16:46 +08:00
$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: '查询成功')]
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 {
2026-08-31 13:16:46 +08:00
$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('用户编码不存在');
}
2026-09-03 19:56:19 +08:00
$repairFlow = $this->getRepairFlowNodeNames();
2026-08-31 13:16:46 +08:00
try {
2026-09-03 19:56:19 +08:00
$record = Db::transaction(function () use ($exceptionId, $repairResult, $categoryIds, $parts, $images, $operationFaultSteps, $responsibleOperators, $data, $member, $repairRecordService, $repairFlow) {
2026-08-31 13:16:46 +08:00
$exception = ExceptionRecord::query()
2026-09-03 19:56:19 +08:00
->with(['process', 'step'])
2026-08-31 13:16:46 +08:00
->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,
2026-09-03 19:56:19 +08:00
'process_id' => $repairFlow['process_id'],
'step_id' => $repairFlow['step_id'],
'process_name' => $repairFlow['process_name'],
'step_name' => $repairFlow['step_name'],
2026-08-31 13:16:46 +08:00
'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('异常提报工序步骤配置无效,请联系管理员');
}
2026-08-31 13:16:46 +08:00
$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(),
]);
2026-08-31 13:16:46 +08:00
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,
2026-08-31 13:16:46 +08:00
'status_name' => (string) $item->status_name,
'result_name' => (string) $item->type_name,
'operator' => (string) $item->reporter,
'user_code' => (string) $item->user_code,
'quantity' => 1,
2026-08-31 13:16:46 +08:00
'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,
2026-09-03 19:56:19 +08:00
'process_name' => (string) ($item->process_name ?: '历史数据未记录'),
'step_name' => (string) ($item->step_name ?: '历史数据未记录'),
2026-08-31 13:16:46 +08:00
'status_name' => '已维修',
'result_name' => (string) $item->repair_result_name,
'operator' => (string) $item->operator,
'user_code' => (string) ($item->exception?->user_code ?? ''),
'quantity' => 1,
2026-08-31 13:16:46 +08:00
'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 = [
2026-09-03 19:56:19 +08:00
'process_id' => $processId > 0 ? $processId : null,
'step_id' => $stepId > 0 ? $stepId : null,
'process_name' => (string) ($process?->process_name ?? '维修'),
'step_name' => (string) ($step?->step_name ?? '维修'),
];
}
2026-08-31 13:16:46 +08:00
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
{
}
}