修复生产统计与维修记录用户数据

This commit is contained in:
2026-09-06 21:06:36 +08:00
parent 02c8fb7021
commit 5ca6ef2d5a
41 changed files with 1838 additions and 205 deletions
@@ -19,6 +19,7 @@ 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\MemberNotification;
use plugin\produce\app\model\Process;
use plugin\produce\app\model\ProcessStep;
use plugin\produce\app\model\ProductRecord;
@@ -367,6 +368,8 @@ final class DataEntryController extends Base
'today_total' => $todayTotal,
'success_total' => $successTotal,
'failed_total' => $failedTotal,
'process_breakdown' => $this->buildTodayProcessStepBreakdown(clone $query),
'daily_trend' => $this->buildDailyTrend(clone $query, 'scan_time'),
]);
}
@@ -422,9 +425,166 @@ final class DataEntryController extends Base
'today_total' => $todayTotal,
'success_total' => $successTotal,
'failed_total' => $failedTotal,
'process_breakdown' => $this->buildTodayProcessStepBreakdown(clone $query),
'daily_trend' => $this->buildDailyTrend(clone $query, 'created_at'),
]);
}
#[OA\Get(path: '/produce/dashboard/today', summary: '获取首页今日生产总览', tags: ['produce-web'])]
#[DataResponse(description: '获取成功')]
public function todayDashboard(): Response
{
$member = $this->currentMember->user();
if (!$member) {
return Json::fail('请先登录', [], 401);
}
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$tomorrowStart = $todayStart + 86400;
$monthStart = strtotime(date('Y-m-01 00:00:00'));
$nextMonthStart = strtotime('+1 month', $monthStart);
$userCode = (string) $member->username;
$orders = ScanData::query()
->where('user_code', $userCode)
->whereBetween('scan_time', [$todayStart, $tomorrowStart - 1]);
$products = ProductRecord::query()
->where('user_code', $userCode)
->whereBetween('created_at', [$todayStart, $tomorrowStart - 1]);
$exceptions = ExceptionRecord::query()
->where('user_code', $userCode)
->whereBetween('reported_at', [$todayStart, $tomorrowStart - 1]);
$operatorNames = array_values(array_unique(array_filter([
trim((string) ($member->nickname ?? '')),
$userCode,
])));
$repairs = RepairRecord::query()
->where(function ($query) use ($operatorNames, $userCode): void {
$query->whereIn('operator', $operatorNames)
->orWhereHas('exception', static fn ($exceptionQuery) => $exceptionQuery->where('user_code', $userCode));
})
->whereBetween('repaired_at', [$todayStart, $tomorrowStart - 1]);
$exceptionTotal = (int) (clone $exceptions)->count();
$responsibilityData = $this->buildMonthlyResponsibilityOrders($monthStart, $nextMonthStart, $userCode);
return Json::success('获取成功', [
'summary' => [
'order_scan' => (int) (clone $orders)->sum('quantity'),
'product_scan' => (int) (clone $products)->sum('quantity'),
'exception' => $exceptionTotal,
'repair' => (int) (clone $repairs)->count(),
],
'order_breakdown' => $this->buildTodayProcessStepBreakdown(clone $orders),
'repair_breakdown' => $this->buildTodayProcessStepBreakdown(clone $repairs),
'product_breakdown' => $this->buildTodayProcessStepBreakdown(clone $products),
'responsibility_order_total' => $responsibilityData['total'],
'responsibility_orders' => $responsibilityData['items'],
'trend' => $this->buildTodayHourlyTrend($orders, $products, $todayStart, $tomorrowStart),
'exceptions' => [
'pending' => (int) (clone $exceptions)->where('status', 0)->count(),
'processing' => (int) (clone $exceptions)->where('status', 1)->count(),
'completed' => (int) (clone $exceptions)->where('status', 2)->count(),
'total' => $exceptionTotal,
],
'updated_at' => time(),
]);
}
/** 统计本月当前会员承担责任的不良订单,并返回最近五条。 */
private function buildMonthlyResponsibilityOrders(int $monthStart, int $nextMonthStart, string $userCode): array
{
$repairs = RepairRecord::query()
->where('repair_result', 'operation_defect')
->whereBetween('repaired_at', [$monthStart, $nextMonthStart - 1])
->orderByDesc('repaired_at')
->orderByDesc('id')
->get(['id', 'order_no', 'exception_reason', 'repaired_at', 'responsible_operators']);
$items = [];
foreach ($repairs as $repair) {
$snapshots = is_array($repair->responsible_operators) ? $repair->responsible_operators : [];
$isResponsible = collect($snapshots)->contains(static fn (array $snapshot): bool => trim((string) ($snapshot['user_code'] ?? '')) === $userCode);
if (!$isResponsible) {
continue;
}
$items[] = [
'id' => (string) $repair->id,
'order_no' => (string) $repair->order_no,
'exception_reason' => (string) $repair->exception_reason,
'quantity' => 1,
'repaired_at' => (int) $repair->repaired_at,
];
}
return [
'total' => count($items),
'items' => array_slice($items, 0, 5),
];
}
/** 统计最近六个整点时段的扫码数量。 */
private function buildTodayHourlyTrend($orders, $products, int $todayStart, int $tomorrowStart): array
{
$trend = [];
$currentHour = strtotime(date('Y-m-d H:00:00'));
for ($offset = 5; $offset >= 0; $offset--) {
$hourStart = $currentHour - ($offset * 3600);
$rangeStart = max($todayStart, $hourStart);
$rangeEnd = min($tomorrowStart - 1, $hourStart + 3599);
$orderCount = (int) (clone $orders)->whereBetween('scan_time', [$rangeStart, $rangeEnd])->sum('quantity');
$productCount = (int) (clone $products)->whereBetween('created_at', [$rangeStart, $rangeEnd])->sum('quantity');
$trend[] = ['label' => date('H:i', $hourStart), 'value' => $orderCount + $productCount];
}
return $trend;
}
/** 汇总今日记录的工序与步骤数量。 */
private function buildTodayProcessStepBreakdown($query): array
{
$isRepairRecord = $query->getModel()->getTable() === 'produce_repair_record';
$quantityExpression = $isRepairRecord ? 'COUNT(*)' : 'SUM(quantity)';
$snapshotColumns = $isRepairRecord ? ', MAX(process_name) as record_process_name, MAX(step_name) as record_step_name' : '';
$rows = $query
->selectRaw("process_id, step_id{$snapshotColumns}, {$quantityExpression} as quantity")
->groupBy('process_id', 'step_id')
->orderByDesc('quantity')
->get();
if ($rows->isEmpty()) {
return [];
}
$processNames = Process::query()
->whereIn('id', $rows->pluck('process_id')->filter()->all())
->pluck('process_name', 'id');
$stepNames = ProcessStep::query()
->whereIn('id', $rows->pluck('step_id')->filter()->all())
->pluck('step_name', 'id');
return $rows->map(static function ($row) use ($processNames, $stepNames): array {
$processId = (string) ($row->process_id ?? '');
$stepId = (string) ($row->step_id ?? '');
return [
'process_id' => $processId,
'step_id' => $stepId,
'process_name' => (string) (($row->record_process_name ?? '') ?: $processNames->get($row->process_id) ?: '未记录工序'),
'step_name' => (string) (($row->record_step_name ?? '') ?: $stepNames->get($row->step_id) ?: '未记录步骤'),
'quantity' => (int) $row->quantity,
];
})->values()->all();
}
/** 汇总查询区间内每日数量。 */
private function buildDailyTrend($query, string $dateColumn): array
{
$rows = $query
->selectRaw("FROM_UNIXTIME({$dateColumn}, '%Y-%m-%d') as day, SUM(quantity) as quantity")
->groupBy('day')
->orderBy('day')
->get();
return $rows->map(static fn ($row): array => [
'date' => (string) $row->day,
'quantity' => (int) $row->quantity,
])->values()->all();
}
#[OA\Get(path: '/produce/product-record/user-code-stats', summary: '前台整机用户统计', tags: ['produce-web'])]
#[DataResponse(description: '获取成功')]
#[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')]
@@ -860,13 +1020,24 @@ final class DataEntryController extends Base
public function repairStatistics(Request $request): Response
{
$query = RepairRecord::query();
$todayStart = strtotime(date('Y-m-d 00:00:00'));
$todayEnd = $todayStart + 86400;
$userCode = trim((string) $request->input('user_code', ''));
$startAt = $this->parseQueryDate($request->input('start_date')) ?: strtotime(date('Y-m-d 00:00:00'));
$endAt = $this->parseQueryDate($request->input('end_date'), true) ?: strtotime(date('Y-m-d 23:59:59'));
if ($userCode !== '') {
$member = Member::query()->where('username', $userCode)->first();
$operators = array_values(array_unique(array_filter([$userCode, (string) ($member?->nickname ?? '')])));
$query->where(function ($builder) use ($operators, $userCode): void {
$builder->whereIn('operator', $operators)
->orWhereHas('exception', static fn ($exceptionQuery) => $exceptionQuery->where('user_code', $userCode));
});
}
$query->whereBetween('repaired_at', [$startAt, $endAt]);
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(),
'completed_today' => (clone $query)->count(),
'process_breakdown' => $this->buildTodayProcessStepBreakdown(clone $query),
]);
}
@@ -1097,6 +1268,16 @@ final class DataEntryController extends Base
'handled_at' => $now,
])->save();
if ($repairResult === 'operation_defect') {
$this->createRepairResponsibilityNotifications(
$repair,
$exception,
$responsibleOperatorSnapshots,
$member,
$now
);
}
return $repair;
});
} catch (\Throwable $e) {
@@ -1106,6 +1287,74 @@ final class DataEntryController extends Base
return Json::success('提交成功', ['id' => (string) $record->id, 'repair_no' => $record->repair_no]);
}
#[OA\Get(path: '/produce/member-notification', summary: '获取当前会员生产通知', tags: ['produce-web'])]
#[DataResponse(description: '获取成功')]
public function memberNotifications(Request $request): Response
{
$memberId = (int) $this->currentMember->id();
if ($memberId <= 0) {
return Json::fail('请先登录', [], 401);
}
$page = max(1, (int) $request->input('page', 1));
$limit = min(50, max(1, (int) $request->input('limit', 20)));
$query = MemberNotification::query()
->where('member_id', $memberId)
->orderByDesc('created_at')
->orderByDesc('id');
$total = $query->count();
return Json::success('ok', [
'items' => $query->forPage($page, $limit)->get(),
'total' => $total,
]);
}
#[OA\Get(path: '/produce/member-notification/unread-count', summary: '获取当前会员未读生产通知数', tags: ['produce-web'])]
#[DataResponse(description: '获取成功')]
public function memberNotificationUnreadCount(): Response
{
$memberId = (int) $this->currentMember->id();
if ($memberId <= 0) {
return Json::fail('请先登录', [], 401);
}
$count = MemberNotification::query()
->where('member_id', $memberId)
->where('status', 0)
->count();
return Json::success('ok', ['unread_count' => $count]);
}
#[OA\Put(path: '/produce/member-notification/read', summary: '标记当前会员生产通知已读', tags: ['produce-web'])]
#[SimpleResponse(description: '操作成功')]
public function readMemberNotification(Request $request): Response
{
$memberId = (int) $this->currentMember->id();
$notificationId = trim((string) $request->input('id', ''));
if ($memberId <= 0) {
return Json::fail('请先登录', [], 401);
}
if ($notificationId === '') {
return Json::fail('通知ID不能为空');
}
$notification = MemberNotification::query()
->where('member_id', $memberId)
->whereKey($notificationId)
->first();
if (!$notification) {
return Json::fail('通知不存在');
}
if ((int) $notification->status === 1) {
return Json::success('ok');
}
$notification->fill(['status' => 1, 'read_at' => time()])->save();
return Json::success('ok');
}
#[OA\Post(path: '/produce/exception/record', summary: '提交异常记录', tags: ['produce-web'])]
#[SimpleResponse(description: '提交成功')]
#[AllowAnonymous(requireToken: false, requirePermission: false, description: '公共接口')]
@@ -1382,9 +1631,17 @@ final class DataEntryController extends Base
protected function applyExceptionRecordFilter($query, Request $request): void
{
$userCode = trim((string) $request->input('user_code', ''));
$startAt = $this->parseQueryDate($request->input('start_date'));
$endAt = $this->parseQueryDate($request->input('end_date'), true);
if ($userCode !== '') {
$query->where('user_code', $userCode);
}
if ($startAt !== null) {
$query->where('reported_at', '>=', $startAt);
}
if ($endAt !== null) {
$query->where('reported_at', '<=', $endAt);
}
}
protected function applyScanDataFilter($query, Request $request): void
@@ -1395,6 +1652,8 @@ final class DataEntryController extends Base
$stepId = (int) $request->input('step_id', 0);
$statusId = (int) $request->input('status_id', 0);
$scanResult = $request->input('scan_result', '');
$startAt = $this->parseQueryDate($request->input('start_date'));
$endAt = $this->parseQueryDate($request->input('end_date'), true);
if ($orderNo !== '') {
$query->where('order_no', $orderNo);
@@ -1415,12 +1674,19 @@ final class DataEntryController extends Base
$query->where('scan_result', (int) $scanResult);
}
if ($startAt !== null) {
$query->where('scan_time', '>=', $startAt);
}
if ($endAt !== null) {
$query->where('scan_time', '<=', $endAt);
}
$recentOnly = (int) ProduceConfig::query()
->where('group_code', 'basic_setting')
->where('code', 'recent_records_today_only')
->value('content');
if ($recentOnly === 1 && $orderNo === '') {
if ($recentOnly === 1 && $orderNo === '' && $startAt === null && $endAt === null) {
$start = strtotime(date('Y-m-d 00:00:00'));
$end = strtotime(date('Y-m-d 23:59:59'));
$query->whereBetween('scan_time', [$start, $end]);
@@ -1429,10 +1695,17 @@ final class DataEntryController extends Base
protected function applyProductRecordFilter($query, Request $request): void
{
$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', '');
$startAt = $this->parseQueryDate($request->input('start_date'));
$endAt = $this->parseQueryDate($request->input('end_date'), true);
if ($userCode !== '') {
$query->where('user_code', $userCode);
}
if ($processId > 0) {
$query->where('process_id', $processId);
@@ -1447,12 +1720,19 @@ final class DataEntryController extends Base
$query->where('scan_result', (int) $scanResult);
}
if ($startAt !== null) {
$query->where('created_at', '>=', $startAt);
}
if ($endAt !== null) {
$query->where('created_at', '<=', $endAt);
}
$recentOnly = (int) ProduceConfig::query()
->where('group_code', 'basic_setting')
->where('code', 'recent_records_today_only')
->value('content');
if ($recentOnly === 1) {
if ($recentOnly === 1 && $startAt === null && $endAt === null) {
$start = strtotime(date('Y-m-d 00:00:00'));
$end = strtotime(date('Y-m-d 23:59:59'));
$query->whereBetween('created_at', [$start, $end]);
@@ -1475,6 +1755,71 @@ final class DataEntryController extends Base
return $data;
}
/** 创建作业不良责任通知,每名责任人仅保留一条通知。 */
private function createRepairResponsibilityNotifications(
RepairRecord $repair,
ExceptionRecord $exception,
array $snapshots,
Member $repairer,
int $now
): void {
$snapshotsByUserCode = [];
foreach ($snapshots as $snapshot) {
$userCode = trim((string) ($snapshot['user_code'] ?? ''));
if ($userCode !== '') {
$snapshotsByUserCode[$userCode][] = $snapshot;
}
}
foreach ($snapshotsByUserCode as $userCode => $userSnapshots) {
$responsibleMember = Member::query()->where('username', $userCode)->first();
if (!$responsibleMember) {
continue;
}
$steps = array_values(array_unique(array_filter(array_map(static function (array $snapshot): string {
return implode(' / ', array_filter([
trim((string) ($snapshot['process_name'] ?? '')),
trim((string) ($snapshot['step_name'] ?? '')),
]));
}, $userSnapshots))));
MemberNotification::query()->create([
'id' => Snowflake::generate(),
'member_id' => $responsibleMember->id,
'user_code' => $userCode,
'type' => 'repair_responsibility',
'title' => '维修作业不良责任通知',
'content' => $this->formatRepairResponsibilityContent($repair, $exception, $steps, $repairer),
'repair_id' => $repair->id,
'repair_no' => $repair->repair_no,
'order_no' => $repair->order_no,
'status' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
/** 组装维修定责通知的原因说明。 */
private function formatRepairResponsibilityContent(
RepairRecord $repair,
ExceptionRecord $exception,
array $steps,
Member $repairer
): string {
$content = [
sprintf('订单 %s 已完成维修,维修判定为作业不良责任。', $repair->order_no),
$steps === [] ? '' : '责任工序/步骤:' . implode('、', $steps),
$exception->reason_name ? '异常原因:' . $exception->reason_name : '',
$exception->issue_name ? '具体问题:' . $exception->issue_name : '',
$repair->remark ? '维修说明:' . $repair->remark : '',
'维修单:' . $repair->repair_no,
'提交人:' . ($repairer->nickname ?: $repairer->username),
];
return implode("\n", array_filter($content));
}
protected function handleNextStepTransitions(ScanData $currentScanData, Request $request, Member $member, int $now): void
{
$previousRelations = \plugin\produce\app\model\StepRelation::query()