1812 lines
80 KiB
PHP
1812 lines
80 KiB
PHP
<?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;
|
|
|
|
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;
|
|
}
|
|
|
|
#[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', ''));
|
|
$processId = (int) $request->input('process_id', 0);
|
|
$stepId = (int) $request->input('step_id', 0);
|
|
$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($processId > 0, fn ($builder) => $builder->where('process_id', $processId));
|
|
$query->when($stepId > 0, fn ($builder) => $builder->where('step_id', $stepId));
|
|
$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($processId > 0, fn ($builder) => $builder->where('process_id', $processId));
|
|
$query->when($stepId > 0, fn ($builder) => $builder->where('step_id', $stepId));
|
|
$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($processId > 0, fn ($builder) => $builder->where('process_id', $processId));
|
|
$query->when($stepId > 0, fn ($builder) => $builder->where('step_id', $stepId));
|
|
$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($processId > 0, fn ($builder) => $builder->where('process_id', $processId));
|
|
$query->when($stepId > 0, fn ($builder) => $builder->where('step_id', $stepId));
|
|
$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 {
|
|
$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('用户编码不存在');
|
|
}
|
|
|
|
$repairFlow = $this->getRepairFlowNodeNames();
|
|
|
|
try {
|
|
$record = Db::transaction(function () use ($exceptionId, $repairResult, $categoryIds, $parts, $images, $operationFaultSteps, $responsibleOperators, $data, $member, $repairRecordService, $repairFlow) {
|
|
$exception = ExceptionRecord::query()
|
|
->with(['process', 'step'])
|
|
->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,
|
|
'process_id' => $repairFlow['process_id'],
|
|
'step_id' => $repairFlow['step_id'],
|
|
'process_name' => $repairFlow['process_name'],
|
|
'step_name' => $repairFlow['step_name'],
|
|
'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' => (string) ($item->process_name ?: '历史数据未记录'),
|
|
'step_name' => (string) ($item->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_id' => $processId > 0 ? $processId : null,
|
|
'step_id' => $stepId > 0 ? $stepId : null,
|
|
'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
|
|
{
|
|
}
|
|
}
|