Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
4beaafd
Удалена панель "Последние отчёты" на станице отчётов.
a-v-kalabuhov May 27, 2026
6aabda9
Выравнены кнопки в справочнике пресс-форм.
a-v-kalabuhov May 27, 2026
ca59e2d
Запрещено создавать задания для неактивных пресс-форм.
a-v-kalabuhov May 27, 2026
ee95709
В дашборде виджет Эффективность заменён виджетом "Загрузка"
a-v-kalabuhov May 27, 2026
b7a3701
В карточке ТПА эффективность заменена на темп и прогноз выполнения за…
a-v-kalabuhov May 27, 2026
c5341d2
В отчёт "Картина рабочего дня" добавлены метки смен.
a-v-kalabuhov May 27, 2026
76b9ff1
Изменения в CLAUDE.md
a-v-kalabuhov May 28, 2026
581f03c
Улучшен UI отчёта "Производительность оборудования".
a-v-kalabuhov May 28, 2026
02ab943
Ячейка Эффективность в отчёте "Производительность оборудования" замен…
a-v-kalabuhov May 28, 2026
6a54666
Добавлены проверки активности в панель управления заданиями.
a-v-kalabuhov May 28, 2026
10eec1f
Добавлены колонки в табице заданий.
a-v-kalabuhov May 28, 2026
ed362f3
Изменён подсчёт просроченных заданий.
a-v-kalabuhov May 28, 2026
ea0e6d6
Удалена панель "Общие настройки"
a-v-kalabuhov May 28, 2026
2ccd7c0
Красота в шаблонах.
a-v-kalabuhov May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,5 @@ publish/

# Secrets
.env
docs/Rosoms_requirements.md
docs/Modules_architecture_plan.md
38 changes: 38 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,44 @@ Tables: `Users` (Identity), `Imms`, `Molds`, `Tasks`, `Templates`, `Events`, `Do

There are no test projects. When adding tests, use xUnit for .NET and Vitest for the frontend.

## Domain Rules

### IsActive — архивный флаг (мягкое удаление)

`IsActive` на сущностях `Imm`, `Mold`, `User` — это флаг вывода из оборота, **не** признак текущей активности.

- `IsActive = true` → сущность в работе, доступна для назначения в СЗ
- `IsActive = false` → сущность в архиве: скрыта из списков выбора, нельзя назначить в новое СЗ, исторические данные сохраняются и доступны в отчётах

Никогда не удалять сущности физически — только `IsActive = false`.

### Статусы ТПА

Статус ТПА передаётся как строка через MQTT, хранится в `ImmStatusHistory.Status` и кешируется в `IImmStatusCache`. Фронтенд интерпретирует строку и показывает пользователю локализованное название и цвет.

| Строка в коде | Отображение пользователю | Смысл |
| ------------- | ------------------------ | -------------------------------------------- |
| `"Auto"` | Авто | ТПА работает по программе (полезная работа) |
| `"Manual"` | Наладка | ТПА в ручном режиме (наладка перед запуском) |
| `"Idle"` | Простой | ТПА включён, но не работает и не в аварии |
| `"Alarm"` | Авария | Аварийное прерывание работы по программе |
| `"Offline"` | Нет связи | От ТПА не поступают MQTT-сообщения |

**Инфраструктура статусов:**
- `ImmStatusHistory` — таблица в БД, хранит историю переходов статусов (открытая запись = текущий статус)
- `IImmStatusCache` / `MemoryImmStatusCache` — in-memory singleton-кеш текущих статусов, заполняется при старте
- `ImmStatusStartupService` — при старте приложения закрывает незавершённые записи истории и заполняет кеш из БД
- `ImmOfflineWorker` — фоновый сервис, каждые 5 сек переводит ТПА в `Offline` если от него нет сообщений
- `IImmStatusService.UpdateStatusAsync` — единственная точка записи нового статуса (обновляет и БД, и кеш)

### Гнёздность (Cavities) и история версий пресс-формы

`Mold.Cavities` — изменяемое поле (при ремонте гнёзда могут заглушаться). Нельзя использовать текущее значение для пересчёта исторических циклов.

**MVP Мун:** `ImmCycle.Cavities` (int) — снапшот на момент записи цикла. Заполняется из `Mold.Cavities` при создании цикла. Fallback для старых записей (= 0) — брать из `Mold.Cavities`.

**РОСОМС и далее:** заменить на `ImmCycle.MoldVersionId` → сущность `MoldVersion`, которая хранит полную конфигурацию ПФ (Cavities, веса, статус) с датой вступления в силу и причиной изменения. `MoldVersion` — фундамент журнала ремонтов и обслуживания.

## Coding Rules

### DateTime и PostgreSQL (Npgsql)
Expand Down
11 changes: 9 additions & 2 deletions Wintime-Control-Frontend/src/api/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,15 @@ export const dashboardApi = {

// Получить активные задания
getActiveTasks() {
return apiClient.get('/tasks', {
params: { status: 'InProgress' }
return apiClient.get('/tasks', {
params: { status: 'InProgress' }
})
},

// Средняя загрузка цеха за период (взвешенная по времени)
getShiftUtilization(from, to) {
return apiClient.get('/dashboard/shift-utilization', {
params: { from: from.toISOString(), to: to.toISOString() }
})
}
}
43 changes: 35 additions & 8 deletions Wintime-Control-Frontend/src/components/dashboard/ImmCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,22 @@
<div class="font-semibold text-gray-800">{{ cycleTime }} сек</div>
</div>
<div>
<div class="text-gray-500 text-xs">Эффективность</div>
<div class="font-semibold" :class="efficiencyColor">{{ efficiency }}%</div>
<div class="text-gray-500 text-xs">Темп</div>
<div class="font-semibold text-gray-800">{{ rateLabel }}</div>
</div>
<div>
<div class="text-gray-500 text-xs">Обновлено</div>
<div class="font-semibold text-gray-800">{{ lastUpdate }}</div>
</div>
</div>

<!-- Прогноз окончания -->
<div v-if="etaLabel" class="mt-2 pt-2 border-t flex items-center gap-2 text-sm">
<el-icon class="text-gray-400"><Clock /></el-icon>
<span class="text-gray-500">Прогноз окончания:</span>
<span class="font-medium text-gray-800">{{ etaLabel }}</span>
</div>

<!-- Индикатор аварии -->
<el-alert
v-if="imm.status === 'Alarm'"
Expand All @@ -84,6 +91,7 @@
<script setup>
import { computed } from 'vue'
import ImmStatusBadge from './ImmStatusBadge.vue'
import { Clock } from '@element-plus/icons-vue'

const props = defineProps({
imm: {
Expand Down Expand Up @@ -111,13 +119,32 @@ const planQuantity = computed(() => props.imm.planQuantity || 0)
const actualQuantity = computed(() => props.imm.actualQuantity || 0)
const cycleCount = computed(() => props.imm.cycleCount || 0)
const cycleTime = computed(() => props.imm.currentCycleTime?.toFixed(1) || '0.0')
const efficiency = computed(() => props.imm.efficiency?.toFixed(1) || '0.0')

const efficiencyColor = computed(() => {
const eff = parseFloat(efficiency.value)
if (eff >= 85) return 'text-green-600'
if (eff >= 70) return 'text-yellow-600'
return 'text-red-600'
// Темп производства: шт/ч на основе фактических штук и времени с начала задания
const ratePerHour = computed(() => {
const actual = props.imm.actualQuantity
const startedAt = props.imm.taskStartedAt
if (!props.imm.currentTaskId || !actual || !startedAt) return null
const elapsedHours = (Date.now() - new Date(startedAt).getTime()) / 3_600_000
if (elapsedHours < 0.01) return null
return actual / elapsedHours
})

const rateLabel = computed(() => {
if (!ratePerHour.value) return '—'
return `${Math.round(ratePerHour.value)} шт/ч`
})

// Прогноз окончания задания
const etaLabel = computed(() => {
const rate = ratePerHour.value
const plan = props.imm.planQuantity
const actual = props.imm.actualQuantity
if (!rate || !plan || actual == null || actual >= plan) return null

const remainingHours = (plan - actual) / rate
const eta = new Date(Date.now() + remainingHours * 3_600_000)
return eta.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
})

const borderColor = computed(() => {
Expand Down
29 changes: 28 additions & 1 deletion Wintime-Control-Frontend/src/components/reports/GanttChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ const props = defineProps({
data: {
type: Array,
default: () => []
},
shifts: {
type: Array,
default: () => []
},
date: {
type: String,
default: ''
}
})

Expand Down Expand Up @@ -67,6 +75,13 @@ const initChart = () => {
})
})

const shiftMarkLines = props.shifts
.filter(s => s.startTime)
.map(s => ({
name: `Смена ${s.number ?? ''} ${s.startTime}`.trim(),
xAxis: dayjs(`${props.date || dayjs().format('YYYY-MM-DD')} ${s.startTime}`).valueOf()
}))

const option = {
tooltip: {
formatter: (params) => {
Expand Down Expand Up @@ -120,7 +135,19 @@ const initChart = () => {
}
},
encode: { x: [1, 2], y: 0 },
data: seriesData
data: seriesData,
markLine: shiftMarkLines.length ? {
silent: true,
symbol: ['none', 'none'],
lineStyle: { color: '#374151', type: 'dashed', width: 1.5 },
label: {
position: 'insideStartTop',
formatter: (p) => p.name,
color: '#374151',
fontSize: 11
},
data: shiftMarkLines
} : undefined
}
]
}
Expand Down
4 changes: 3 additions & 1 deletion Wintime-Control-Frontend/src/layouts/DefaultLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
<el-menu-item index="/dictionary/molds">Пресс-формы</el-menu-item>
<el-menu-item index="/dictionary/personnel">Персонал</el-menu-item>
<el-menu-item index="/dictionary/shifts">Смены</el-menu-item>
<el-menu-item index="/dictionary/downtime-reasons">Причины простоев</el-menu-item>
</el-sub-menu>
<!-- Смены отдельно для Observer (для Admin/Manager — внутри Справочников) -->
<el-menu-item
Expand Down Expand Up @@ -172,7 +173,8 @@ const pageTitle = computed(() => {
'/dictionary/imm': 'Справочник ТПА',
'/dictionary/molds': 'Справочник пресс-форм',
'/dictionary/personnel': 'Справочник персонала',
'/dictionary/shifts': 'Расписание смен'
'/dictionary/shifts': 'Расписание смен',
'/dictionary/downtime-reasons': 'Причины простоев'
}
return titles[route.path] || 'CONTROL'
})
Expand Down
6 changes: 6 additions & 0 deletions Wintime-Control-Frontend/src/router/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ const routes = [
name: 'DictionaryShifts',
component: () => import('@/views/dictionary/ShiftsDictionary.vue'),
meta: { roles: ['Admin', 'Manager', 'Observer'] }
},
{
path: 'downtime-reasons',
name: 'DictionaryDowntimeReasons',
component: () => import('@/views/dictionary/DowntimeDictionary.vue'),
meta: { roles: ['Admin', 'Manager'] }
}
]
},
Expand Down
76 changes: 70 additions & 6 deletions Wintime-Control-Frontend/src/stores/dashboard.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { dashboardApi } from '@/api/dashboard'
import { immApi } from '@/api/imm'
import { shiftsApi } from '@/api/shifts'

export const useDashboardStore = defineStore('dashboard', {
state: () => ({
Expand All @@ -13,7 +14,10 @@ export const useDashboardStore = defineStore('dashboard', {
filters: {
status: null,
search: ''
}
},
shifts: [],
shiftUtilization: null, // { utilization, machineCount, from, to }
shiftUtilizationLoading: false
}),

getters: {
Expand All @@ -35,12 +39,48 @@ export const useDashboardStore = defineStore('dashboard', {
// ТПА оффлайн (Offline)
offlineImms: (state) => state.imms.filter(i => i.status === 'Offline'),

// Общая эффективность цеха
// Мгновенная загрузка цеха: (Auto + Manual) / все активные
overallEfficiency: (state) => {
const working = state.imms.filter(i => i.status === 'Auto')
if (working.length === 0) return 0
const avg = working.reduce((sum, i) => sum + (i.efficiency || 0), 0) / working.length
return Math.round(avg)
if (state.imms.length === 0) return 0
const active = state.imms.filter(i => i.status === 'Auto' || i.status === 'Manual')
return Math.round(active.length / state.imms.length * 100)
},

// Текущая смена или null
currentShift: (state) => {
if (state.shifts.length === 0) return null
const now = new Date()
const minutesNow = now.getHours() * 60 + now.getMinutes()
return state.shifts.find(s => {
const end = s.startMinutes + s.durationMinutes
if (end <= 1440) {
return minutesNow >= s.startMinutes && minutesNow < end
}
// смена переходит через полночь
return minutesNow >= s.startMinutes || minutesNow < (end % 1440)
}) ?? null
},

// Последняя завершённая смена (ближайшая к текущему моменту)
lastCompletedShift: (state) => {
if (state.shifts.length === 0) return null
const now = new Date()
const minutesNow = now.getHours() * 60 + now.getMinutes()
// Ищем смену, которая закончилась позже всего, но раньше текущего момента
let best = null
let bestEnd = -1
for (const s of state.shifts) {
const end = (s.startMinutes + s.durationMinutes) % 1440
// Сколько минут назад закончилась смена
const minutesAgo = (minutesNow - end + 1440) % 1440
if (minutesAgo > 0 && minutesAgo < 1440) {
if (best === null || minutesAgo < (minutesNow - bestEnd + 1440) % 1440) {
best = s
bestEnd = end
}
}
}
return best
},

// Фильтрованный список ТПА
Expand Down Expand Up @@ -68,6 +108,30 @@ export const useDashboardStore = defineStore('dashboard', {
},

actions: {
// Загрузка расписания смен
async loadShifts() {
try {
const response = await shiftsApi.getShifts()
this.shifts = response.data
} catch (error) {
console.error('Ошибка загрузки смен:', error)
}
},

// Загрузка средней загрузки за смену
async loadShiftUtilization(from, to) {
this.shiftUtilizationLoading = true
try {
const response = await dashboardApi.getShiftUtilization(from, to)
this.shiftUtilization = response.data
} catch (error) {
console.error('Ошибка загрузки загрузки смены:', error)
this.shiftUtilization = null
} finally {
this.shiftUtilizationLoading = false
}
},

// Загрузка списка ТПА
async loadImms() {
this.loading = true
Expand Down
20 changes: 6 additions & 14 deletions Wintime-Control-Frontend/src/stores/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,24 +34,16 @@ export const useTasksStore = defineStore('tasks', {
const now = new Date()
return state.tasks.filter(t => {
if (t.status === 'Completed' || t.status === 'Closed') return false
if (t.plannedDate) {
const endOfDay = new Date(t.plannedDate)
endOfDay.setHours(23, 59, 59, 999)
return now > endOfDay
}
if (!t.issuedAt) return false
const issuedDate = new Date(t.issuedAt)
const hoursDiff = (now - issuedDate) / (1000 * 60 * 60)
return hoursDiff > 12 // Более 12 часов в работе
return (now - new Date(t.issuedAt)) / (1000 * 60 * 60) > 12
})
},

// Общая эффективность выполнения
overallProgress: (state) => {
const activeTasks = state.tasks.filter(t =>
t.status === 'InProgress' || t.status === 'Issued'
)
if (activeTasks.length === 0) return 0

const totalProgress = activeTasks.reduce((sum, t) => sum + (t.progressPercent || 0), 0)
return Math.round(totalProgress / activeTasks.length)
},

// Фильтрованный список
filteredTasks: (state) => {
let result = state.tasks
Expand Down
19 changes: 0 additions & 19 deletions Wintime-Control-Frontend/src/views/admin/SettingsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,6 @@
</el-form>
</el-card>

<el-card class="mb-6" visible="false">
<template #header>
<span class="font-semibold">Общие настройки</span>
</template>

<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="Таймаут сессии (мин)">
<el-input-number v-model="settings.sessionTimeoutMinutes" :min="5" :max="1440" class="w-full" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Интервал телеметрии (сек)">
<el-input-number v-model="settings.telemetryIntervalSeconds" :min="1" :max="60" class="w-full" />
</el-form-item>
</el-col>
</el-row>
</el-card>

<div class="flex justify-end gap-4">
<el-button @click="loadSettings">Отмена</el-button>
<el-button type="primary" @click="saveSettings" :loading="saving">Сохранить</el-button>
Expand Down
Loading
Loading