Rename the equipment domain to machines; retype the models catalog (ADR-011)
All checks were successful
CI / backend (push) Successful in 23s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s

The equipment plugin is now the machines plugin, ending the UI-vs-code
vocabulary split while the contract is pre-1.0 and nothing external
depends on the old names.

- plugins/equipment -> plugins/machines: manifest, class, /api/machines,
  machines.* permissions, registry key (with an auto-migrating load shim
  for existing installs).
- Tables: equipment -> machines (equipmentid -> machineid) and
  equipmenttypes -> machinetypes, renamed in the plugin's own migration
  chain (machines0002rename), idempotent for both upgrading and fresh
  installs.
- The legacy core machinetypes lookup actually types the vendor MODELS
  catalog, so it is renamed losslessly to modeltypes
  (models.modeltypeid, /api/modeltypes, Model Types settings page)
  rather than collapsed, freeing the machinetypes name. Core migration
  7d17_machines_rename also flips data in place: assettypes row
  equipment -> machine, auditlog entitytype, identifier_/search_
  settings keys, permission rows, and renames alembic_version_equipment.
- Frontend: machinesApi/modeltypesApi, item.machine response shape,
  assettype value compares 'equipment' -> 'machine' (map, search,
  custom fields, relationships), routes machines.js with plugin gating
  retagged, /print/machine-badge, Machine Types (subtypes) and Model
  Types (catalog) settings pages, machines-by-type report id.
- Docs swept; ADRs left as history per the authoring rule.

Upgrade: flask db upgrade then flask plugin upgrade-all.

Verified: dev DB flipped live (262 machines, 35 modeltypes, 95 models
retyped, zero equipment tables remain); fresh scratch-MySQL install
produces the new names; 341 tests green; naming/style green; frontend
builds; live E2E on machines list/detail, PC relationships, map,
reports, and both settings pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-11 15:17:42 -04:00
parent 3c43c8d5c8
commit 48d3160bc5
84 changed files with 4755 additions and 4317 deletions

View File

@@ -41,6 +41,22 @@ ADR-007 and ADR-002.
### Changed
- Equipment -> machines rename (backend). The equipment plugin is now the
machines plugin: `/api/equipment` -> `/api/machines`, tables
`equipment`/`equipmenttypes` -> `machines`/`machinetypes` (columns
`equipmentid` -> `machineid`, `equipmenttypeid` -> `machinetypeid`,
`equipmenttype` -> `machinetype`), permissions `equipment.*` ->
`machines.*`, assettype value `equipment` -> `machine`. The legacy core
`machinetypes` lookup (it types the vendor MODELS catalog, not machine
instances) is renamed to `modeltypes` (`/api/machinetypes` ->
`/api/modeltypes`, `models.machinetypeid` -> `models.modeltypeid`) to
free the name. Data flips migrate assettypes, auditlog entitytype,
settings keys (`identifier_*_equipment_enabled` ->
`identifier_*_machine_enabled`, `search_equipment_enabled` ->
`search_machine_enabled`), and permission rows in place; plugins.json
registry entries carry over automatically. Upgrade: run
`flask db upgrade` then `flask plugin upgrade-all`.
- Inter (variable) replaces Roboto, bundled locally - no Google Fonts
fetch, so air-gapped installs render correctly. Tables use tabular
numerals.

View File

@@ -38,7 +38,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progr
- 340 tests passing, naming/style check green, Gitea Actions CI (backend + naming + frontend build)
- `__contract_version__` at 0.6.0 (product `__version__` 0.5.0 - distinct series, ADR-007)
- 11 bundled plugins all satisfy contract: computers, employees, equipment, knowledgebase, measuringtools, network, notifications, printers, slides, usb, warranty
- 11 bundled plugins all satisfy contract: computers, employees, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty
- Single core Alembic chain: baseline `68b3947ae14f` -> head `7d16_directoryemployees` (23 migrations). A fresh site runs `flask db upgrade` from empty; it is reproducible and idempotent.
- Pre-1.0 framework; sister sites should pin tight `core_version` ranges until contract reaches 1.0

View File

@@ -198,7 +198,7 @@ Query parameters for list endpoints:
- `sort` - Sort field
- `order` - Sort direction (asc/desc)
- `search` - Search term
- `type` - Filter by machine type (pc, printer, equipment)
- `type` - Filter by asset type (computer, printer, machine, network_device)
## Plugin System
@@ -208,7 +208,7 @@ The image bundles ten plugins; only the ones a site installs are loaded:
- **computers** - Shopfloor PCs and workstations
- **employees** - Employee directory
- **equipment** - CNC, CMM, and inspection equipment
- **machine** - CNC, CMM, and other shop-floor machines
- **knowledgebase** - Documentation and troubleshooting guides
- **network** - Network devices
- **notifications** - Shopfloor notifications and recognition feed

View File

@@ -146,7 +146,7 @@ them under `instance/branding/`.
|-----|---------|-------|
| `site_logo` | `/ge-aerospace-logo.svg` | Header and login-page logo. |
| `qr_logo` | `/ge-monogram.svg` | Logo composited in printer QR labels. Blank = no overlay. |
| `badge_logo` | `/ge-aerospace-logo.svg` | Logo on the equipment badge print page. |
| `badge_logo` | `/ge-aerospace-logo.svg` | Logo on the machine badge print page. |
| `site_favicon` | (empty) | Browser tab favicon. Blank = shipped `/favicon.svg`. |
| `brand_primary_color` | (empty) | Primary brand color as a CSS color value (maps to `--primary`). Blank = built-in theme color. |
| `brand_primary_dark_color` | (empty) | Primary hover/active color (maps to `--primary-dark`). Blank = auto-derived by darkening the primary color ~15%. |

View File

@@ -104,12 +104,12 @@ docker compose exec api flask seed reference-data
## Step 5: Pick plugins to enable
The image bundles ten plugins (computers, employees, equipment, knowledgebase, network, notifications, printers, slides, usb, warranty). Only enabled plugins are loaded.
The image bundles eleven plugins (computers, employees, knowledgebase, machines, measuringtools, network, notifications, printers, slides, usb, warranty). Only enabled plugins are loaded.
```bash
docker compose exec api flask plugin list
docker compose exec api flask plugin install computers
docker compose exec api flask plugin install equipment
docker compose exec api flask plugin install machines
# ... repeat for each plugin the site tracks
```

View File

@@ -109,7 +109,7 @@ def meta(self) -> PluginMeta:
The plugin owns two tables (`plugins/measuringtools/models/measuringtool.py`):
- `measuringtooltypes` - the site-managed lookup (Caliper, Micrometer, ...) with a
display color, the same shape as `equipmenttypes` / `computertypes`.
display color, the same shape as `machinetypes` / `computertypes`.
- `measuringtools` - the one-to-one extension of a core `Asset`, carrying only the
metrology domain fields.
@@ -523,7 +523,7 @@ form is `requiresAuth`; the settings subtype page is `requiresAuth + requiresAdm
**API client, addition only.** `frontend/src/api/index.js` gets a
`measuringtoolsApi` object appended after `warrantyApi` (`list`, `get`, `create`,
`update`, `remove`, `calibrationReport`, and a nested `types` CRUD). Do not
reorganize the file; just add the block, mirroring `equipmentApi`.
reorganize the file; just add the block, mirroring `machinesApi`.
**Views mirror the master templates.** The frontend has master templates the
frontend CLAUDE.md points to (`PrintersList.vue` for lists, `PrinterDetail.vue`

View File

@@ -8,7 +8,7 @@ These plugins are in `plugins/` in this repo. Enable per site with `flask plugin
| Plugin | Tracks | Notes |
|--------|--------|-------|
| `equipment` | Manufacturing machinery: 5-axis mills, lathes, broachers, heat treatment ovens | Manually entered. See [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md). Subtype tables for FOCAS / CLM / MTConnect controller protocols (planned). |
| `machines` | Manufacturing machinery: 5-axis mills, lathes, broachers, heat treatment ovens | Manually entered. See [ADR-005](adr/ADR-005-equipment-vs-measuringtools.md). Subtype tables for FOCAS / CLM / MTConnect controller protocols (planned). |
| `computers` | Shop-floor PCs and engineering workstations | Fed by the PXE pipeline collector per [ADR-006](adr/ADR-006-collector-contract.md). |
| `printers` | Network and shop-floor printers | Optional Zabbix integration for supply tracking. Legacy `PrinterData` retiring per ADR-001. |
| `network` | Switches, routers, access points, IDFs as locations | Asset-only; cleanest of the bundled set. |

View File

@@ -33,7 +33,7 @@ shopdb-flask is at `__contract_version__ = '0.5.0'` (pre-1.0). This document cap
- Frontend scaffolding skill (the backend has `flask plugin new`; the frontend stub is currently manual copy-paste).
- Marketplace listing site (PLUGINS.md is a one-pager; a proper listing with links to sister-site plugins becomes useful when there are more than three external plugins).
- Plugin contract surface diff tooling. Today version bumps are manual judgment; a CI check that diffs the contract surface against the previous tag would catch missed bumps. See ADR-002.
- Calibration cycles, maintenance windows, downtime tracking (domain extensions; would likely live in the `equipment` and `measuringtools` plugins).
- Calibration cycles, maintenance windows, downtime tracking (domain extensions; would likely live in the `machines` and `measuringtools` plugins).
### Deferred (out of scope for 1.0)

View File

@@ -58,45 +58,45 @@ export const authApi = {
}
}
// Equipment API (plugin)
export const equipmentApi = {
// Machines API (plugin)
export const machinesApi = {
list(params = {}) {
return api.get('/equipment', { params })
return api.get('/machines', { params })
},
get(id) {
return api.get(`/equipment/${id}`)
return api.get(`/machines/${id}`)
},
getByAsset(assetId) {
return api.get(`/equipment/by-asset/${assetId}`)
return api.get(`/machines/by-asset/${assetId}`)
},
create(data) {
return api.post('/equipment', data)
return api.post('/machines', data)
},
update(id, data) {
return api.put(`/equipment/${id}`, data)
return api.put(`/machines/${id}`, data)
},
delete(id) {
return api.delete(`/equipment/${id}`)
return api.delete(`/machines/${id}`)
},
dashboardSummary() {
return api.get('/equipment/dashboard/summary')
return api.get('/machines/dashboard/summary')
},
// Equipment types
// Machine types
types: {
list(params = {}) {
return api.get('/equipment/types', { params })
return api.get('/machines/types', { params })
},
get(id) {
return api.get(`/equipment/types/${id}`)
return api.get(`/machines/types/${id}`)
},
create(data) {
return api.post('/equipment/types', data)
return api.post('/machines/types', data)
},
update(id, data) {
return api.put(`/equipment/types/${id}`, data)
return api.put(`/machines/types/${id}`, data)
},
remove(id) {
return api.delete(`/equipment/types/${id}`)
return api.delete(`/machines/types/${id}`)
}
}
}
@@ -178,19 +178,19 @@ export const relationshipTypesApi = {
}
}
// Machine Types API
export const machinetypesApi = {
// Model Types API (the vendor models catalog: modeltypeid)
export const modeltypesApi = {
list(params = {}) {
return api.get('/machinetypes', { params })
return api.get('/modeltypes', { params })
},
create(data) {
return api.post('/machinetypes', data)
return api.post('/modeltypes', data)
},
update(id, data) {
return api.put(`/machinetypes/${id}`, data)
return api.put(`/modeltypes/${id}`, data)
},
delete(id) {
return api.delete(`/machinetypes/${id}`)
return api.delete(`/modeltypes/${id}`)
}
}
@@ -667,8 +667,8 @@ export const reportsApi = {
list() {
return api.get('/reports')
},
equipmentByType(params = {}) {
return api.get('/reports/equipment-by-type', { params })
machinesByType(params = {}) {
return api.get('/reports/machines-by-type', { params })
},
assetsByStatus(params = {}) {
return api.get('/reports/assets-by-status', { params })

View File

@@ -976,7 +976,7 @@ td.actions {
width: 180px;
height: 180px;
object-fit: contain;
background: rgba(0, 0, 0, 0.3);
background: var(--bg);
border-radius: 0.25rem;
padding: 1rem;
}
@@ -1208,15 +1208,16 @@ td.actions {
align-items: center;
justify-content: space-between;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.3);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.25rem;
text-decoration: none;
color: inherit;
transition: background 0.15s;
transition: border-color 0.15s;
}
.equipment-item:hover {
background: rgba(255, 255, 255, 0.1);
border-color: var(--primary);
}
.equipment-info {
@@ -1251,7 +1252,8 @@ td.actions {
.network-item {
padding: 0.75rem;
background: rgba(0, 0, 0, 0.3);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.25rem;
}

View File

@@ -27,14 +27,14 @@
:key="rel.relationshipid"
class="relationship-item"
>
<div class="rel-icon"><component :is="getAssetIcon(rel.targetasset?.assettype)" :size="16" /></div>
<div class="rel-icon"><component :is="getAssetIcon(rel.targetasset?.assettypename || rel.targetasset?.assettype)" :size="16" /></div>
<div class="rel-content">
<router-link :to="getAssetRoute(rel.targetasset)" class="rel-name">
{{ rel.targetasset?.name || rel.targetasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.targetasset?.assettype }}</span>
<span class="rel-type-badge">{{ rel.targetasset?.assettypename || rel.targetasset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
</div>
@@ -59,14 +59,14 @@
:key="rel.relationshipid"
class="relationship-item"
>
<div class="rel-icon"><component :is="getAssetIcon(rel.sourceasset?.assettype)" :size="16" /></div>
<div class="rel-icon"><component :is="getAssetIcon(rel.sourceasset?.assettypename || rel.sourceasset?.assettype)" :size="16" /></div>
<div class="rel-content">
<router-link :to="getAssetRoute(rel.sourceasset)" class="rel-name">
{{ rel.sourceasset?.name || rel.sourceasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.sourceasset?.assettype }}</span>
<span class="rel-type-badge">{{ rel.sourceasset?.assettypename || rel.sourceasset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
</div>
@@ -173,7 +173,7 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Package } from 'lucide-vue-next'
import { Cog, Monitor, Printer, Globe, Package, Ruler } from 'lucide-vue-next'
import { assetsApi, relationshipTypesApi } from '../api'
import { colorStyle } from '@/utils/colorStyle'
import { useAuthStore } from '../stores/auth'
@@ -384,10 +384,11 @@ function closeModal() {
function getAssetIcon(assettype) {
const icons = {
'equipment': Cog,
'machine': Cog,
'computer': Monitor,
'printer': Printer,
'network_device': Globe
'network_device': Globe,
'measuring_tool': Ruler
}
return icons[assettype] || Package
}
@@ -396,21 +397,22 @@ function getAssetRoute(asset) {
if (!asset) return '#'
const routeMap = {
'equipment': '/machines',
'machine': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network'
'network_device': '/network',
'measuring_tool': '/measuringtools'
}
const basePath = routeMap[asset.assettype] || '/assets'
// serializer sends assettypename; old shape used assettype
const typename = asset.assettypename || asset.assettype
const basePath = routeMap[typename] || '/assets'
// Use typedata ID if available
if (asset.assettype === 'network_device' && asset.typedata?.networkdeviceid) {
return `/network/${asset.typedata.networkdeviceid}`
}
// For equipment/computer/printer, use machineid from typedata or assetid
const id = asset.typedata?.machineid || asset.assetid
// pluginid is the extension-table id the detail routes key on
const id = asset.pluginid
|| asset.typedata?.networkdeviceid
|| asset.typedata?.machineid
|| asset.assetid
return `${basePath}/${id}`
}
</script>

View File

@@ -128,7 +128,7 @@ let MAP_HEIGHT = mapConfig.height
// Asset type colors (for unified map mode) - normalized lookup
const assetTypeColorsMap = {
'equipment': '#F44336', // Red
'machine': '#F44336', // Red
'computer': '#2196F3', // Blue
'printer': '#4CAF50', // Green
'network device': '#FF9800', // Orange
@@ -225,7 +225,7 @@ function getSubtypeId(asset) {
if (!asset.typedata) return null
// Normalize network_device -> network device so the subtype id resolves.
const typeLower = (asset.assettype || '').toLowerCase().replace(/_/g, ' ')
if (typeLower === 'equipment') return asset.typedata.equipmenttypeid
if (typeLower === 'machine') return asset.typedata.machinetypeid
if (typeLower === 'computer') return asset.typedata.computertypeid
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
if (typeLower === 'printer') return asset.typedata.printertypeid
@@ -346,7 +346,7 @@ function clearPosition() {
function getDetailRoute(machine) {
const category = machine.category?.toLowerCase() || ''
const routeMap = {
'equipment': '/machines',
'machine': '/machines',
'pc': '/pcs',
'printer': '/printers',
// Future addon routes can be added here:

View File

@@ -14,7 +14,7 @@ const state = reactive({
let inflight = null
const KEY_RE = /^identifier_(.+?)(?:_(equipment|computer|printer|network_device))?_enabled$/
const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device))?_enabled$/
function applySetting(key, value) {
const match = KEY_RE.exec(key)

View File

@@ -66,9 +66,9 @@ const routes = [
},
// Print pages (standalone, no sidebar/header)
{
path: '/print/equipment-badge/:id',
name: 'print-equipment-badge',
component: () => import('../views/print/EquipmentBadge.vue')
path: '/print/machine-badge/:id',
name: 'print-machine-badge',
component: () => import('../views/print/MachineBadge.vue')
},
{
path: '/print/printer-qr',

View File

@@ -60,9 +60,9 @@ export default [
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/machinetypes',
name: 'machinetypes',
component: () => import('../../views/settings/MachineTypesList.vue'),
path: 'settings/modeltypes',
name: 'modeltypes',
component: () => import('../../views/settings/ModelTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
@@ -108,9 +108,9 @@ export default [
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'slides' }
},
{
path: 'settings/equipmenttypes',
name: 'equipment-types',
component: () => import('../../views/settings/EquipmentTypesList.vue'),
path: 'settings/machinetypes',
name: 'machinetypes',
component: () => import('../../views/settings/MachineTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{

View File

@@ -1,29 +1,29 @@
/**
* Equipment plugin routes
* Machines plugin routes
*/
export default [
{
path: 'machines',
name: 'machines',
component: () => import('../../views/machines/MachinesList.vue'),
meta: { plugin: 'equipment' }
meta: { plugin: 'machines' }
},
{
path: 'machines/new',
name: 'machine-new',
component: () => import('../../views/machines/MachineForm.vue'),
meta: { requiresAuth: true, plugin: 'equipment' }
meta: { requiresAuth: true, plugin: 'machines' }
},
{
path: 'machines/:id',
name: 'machine-detail',
component: () => import('../../views/machines/MachineDetail.vue'),
meta: { plugin: 'equipment' }
meta: { plugin: 'machines' }
},
{
path: 'machines/:id/edit',
name: 'machine-edit',
component: () => import('../../views/machines/MachineForm.vue'),
meta: { requiresAuth: true, plugin: 'equipment' }
meta: { requiresAuth: true, plugin: 'machines' }
}
]

View File

@@ -4,7 +4,7 @@
// because the API sends the underscore form and some map data the spaced form.
const ASSET_TYPE_LABELS = {
'equipment': 'Equipment',
'machine': 'Machines',
'computer': 'Computers',
'printer': 'Printers',
'network_device': 'Network Devices',
@@ -12,7 +12,7 @@ const ASSET_TYPE_LABELS = {
}
const ASSET_TYPE_ROUTES = {
'equipment': '/machines',
'machine': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network',
@@ -21,7 +21,7 @@ const ASSET_TYPE_ROUTES = {
// Plugin-specific id field inside asset.typedata for each asset type.
const ASSET_TYPE_ID_KEYS = {
'equipment': 'equipmentid',
'machine': 'machineid',
'computer': 'computerid',
'printer': 'printerid',
'network_device': 'networkdeviceid',

View File

@@ -3,7 +3,7 @@
// ShopFloorMap.vue - keep the two in sync if the palette changes.
export const assetTypeColorsMap = {
equipment: '#F44336', // Red
machine: '#F44336', // Red
computer: '#2196F3', // Blue
printer: '#4CAF50', // Green
'network device': '#FF9800', // Orange
@@ -15,7 +15,7 @@ const DEFAULT_COLOR = '#BDBDBD'
// Canonical form for comparing asset-type strings. The API sends the machine
// type as 'network_device' but subtype keys and labels use 'network device',
// so normalize underscores to spaces before any comparison. Without this,
// network-device subtypes silently fail to match (equipment/computer/printer
// network-device subtypes silently fail to match (machine/computer/printer
// are single words and were unaffected, which is why only network broke).
export function normalizeAssetType(assettype) {
return (assettype || '').toLowerCase().replace(/_/g, ' ')
@@ -33,7 +33,7 @@ export function getAssetTypeColor(assettype) {
export function getSubtypeId(asset) {
if (!asset || !asset.typedata) return null
const typeLower = normalizeAssetType(asset.assettype)
if (typeLower === 'equipment') return asset.typedata.equipmenttypeid
if (typeLower === 'machine') return asset.typedata.machinetypeid
if (typeLower === 'computer') return asset.typedata.computertypeid
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
if (typeLower === 'printer') return asset.typedata.printertypeid

View File

@@ -50,7 +50,7 @@ export async function getQrLogo() {
return (value === undefined || value === null) ? '/ge-monogram.svg' : value
}
// Logo printed on equipment inspection badges.
// Logo printed on machine inspection badges.
export async function getBadgeLogo() {
return getSetting('badge_logo', '/ge-aerospace-logo.svg')
}

View File

@@ -15,7 +15,7 @@
</div>
<div class="stat-card success">
<div class="label">Active</div>
<div class="value">{{ stats.activemachines || 0 }}</div>
<div class="value">{{ stats.activeassets || 0 }}</div>
</div>
<div class="stat-card warning">
<div class="label">In Repair</div>

View File

@@ -14,7 +14,7 @@
<h3>Assets</h3>
<select v-model="filterType" class="form-control">
<option value="">All Types</option>
<option value="equipment">Machines</option>
<option value="machine">Machines</option>
<option value="computer">Computers</option>
<option value="printer">Printers</option>
<option value="network_device">Network Devices</option>
@@ -155,7 +155,7 @@ async function loadAssets() {
function getTypeIcon(assettype) {
const icons = {
'equipment': Cog,
'machine': Cog,
'computer': Monitor,
'printer': Printer,
'network_device': Globe

View File

@@ -130,7 +130,7 @@ const subtypeLabel = computed(() => {
if (!selectedType.value) return 'Select type first'
if (!currentSubtypes.value.length) return 'No subtypes'
const labels = {
'equipment': 'All Machine Types',
'machine': 'All Machine Types',
'computer': 'All Computer Types',
'network device': 'All Device Types',
'printer': 'All Printer Types'
@@ -182,8 +182,8 @@ const filteredAssets = computed(() => {
result = result.filter(a => {
if (!a.typedata) return false
// Check different ID fields based on asset type
if (typeLower === 'equipment') {
return a.typedata.equipmenttypeid === subtypeId
if (typeLower === 'machine') {
return a.typedata.machinetypeid === subtypeId
} else if (typeLower === 'computer') {
return a.typedata.computertypeid === subtypeId
} else if (typeLower === 'network device') {

View File

@@ -110,7 +110,6 @@ const copiedId = ref(null)
const typeLabels = {
machine: 'Machine',
equipment: 'Machine',
pc: 'PC',
computer: 'PC',
application: 'App',
@@ -124,7 +123,7 @@ const typeLabels = {
const filterTypeMap = {
all: null,
equipment: ['equipment'],
machines: ['machine'],
computers: ['computer'],
printers: ['printer'],
network: ['network_device', 'subnet'],
@@ -136,7 +135,7 @@ const filterTypeMap = {
const filterList = [
{ key: 'all', label: 'All' },
{ key: 'equipment', label: 'Machines' },
{ key: 'machines', label: 'Machines' },
{ key: 'computers', label: 'PCs' },
{ key: 'printers', label: 'Printers' },
{ key: 'network', label: 'Network' },
@@ -383,8 +382,7 @@ watch(results, () => {
flex-shrink: 0;
}
.result-type.machine,
.result-type.equipment {
.result-type.machine {
background: #e3f2fd;
color: #1565c0;
}
@@ -484,8 +482,7 @@ watch(results, () => {
}
@media (prefers-color-scheme: dark) {
.result-type.machine,
.result-type.equipment {
.result-type.machine {
background: rgba(21, 101, 192, 0.2);
color: #64b5f6;
}

View File

@@ -3,10 +3,10 @@
<div class="page-header">
<h2>Machine Details</h2>
<div class="header-actions">
<router-link :to="`/print/equipment-badge/${equipment?.equipment?.equipmentid}`" class="btn btn-secondary" v-if="equipment" target="_blank">
<router-link :to="`/print/machine-badge/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
Print Badge
</router-link>
<router-link :to="`/machines/${equipment?.equipment?.equipmentid}/edit`" class="btn btn-primary" v-if="equipment">
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
Edit
</router-link>
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
@@ -15,20 +15,20 @@
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="equipment">
<template v-else-if="machine">
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-content">
<div class="hero-title">
<h1>{{ equipment.assetnumber }}</h1>
<span v-if="equipment.name" class="hero-alias">{{ equipment.name }}</span>
<h1>{{ machine.assetnumber }}</h1>
<span v-if="machine.name" class="hero-alias">{{ machine.name }}</span>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-primary">
{{ equipment.assettypename || 'Machine' }}
{{ machine.assettypename || 'Machine' }}
</span>
<span class="badge badge-lg" :class="getStatusClass(equipment.statusname)">
{{ equipment.statusname || 'Unknown' }}
<span class="badge badge-lg" :class="getStatusClass(machine.statusname)">
{{ machine.statusname || 'Unknown' }}
</span>
<span v-if="heroWarranty" class="badge badge-lg" :style="{ background: heroWarranty.statuscolor, color: '#fff' }"
:title="heroWarranty.enddate ? `Warranty ends ${warrantyDate(heroWarranty.enddate)}` : 'Warranty'">
@@ -36,21 +36,21 @@
</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="equipment.equipment?.equipmenttypename">
<div class="hero-detail" v-if="machine.machine?.machinetypename">
<span class="hero-detail-label">Type</span>
<span class="hero-detail-value">{{ equipment.equipment.equipmenttypename }}</span>
<span class="hero-detail-value">{{ machine.machine.machinetypename }}</span>
</div>
<div class="hero-detail" v-if="equipment.equipment?.vendorname">
<div class="hero-detail" v-if="machine.machine?.vendorname">
<span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ equipment.equipment.vendorname }}</span>
<span class="hero-detail-value">{{ machine.machine.vendorname }}</span>
</div>
<div class="hero-detail" v-if="equipment.equipment?.modelname">
<div class="hero-detail" v-if="machine.machine?.modelname">
<span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ equipment.equipment.modelname }}</span>
<span class="hero-detail-value">{{ machine.machine.modelname }}</span>
</div>
<div class="hero-detail" v-if="equipment.locationname">
<div class="hero-detail" v-if="machine.locationname">
<span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ equipment.locationname }}</span>
<span class="hero-detail-value">{{ machine.locationname }}</span>
</div>
</div>
</div>
@@ -66,23 +66,23 @@
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value">{{ equipment.assetnumber }}</span>
<span class="info-value">{{ machine.assetnumber }}</span>
</div>
<div class="info-row" v-if="equipment.name">
<div class="info-row" v-if="machine.name">
<span class="info-label">Name</span>
<span class="info-value">{{ equipment.name }}</span>
<span class="info-value">{{ machine.name }}</span>
</div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'equipment') && equipment.gaugelabreference">
<div class="info-row" v-if="isEnabled('gaugelabreference', 'machine') && machine.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ equipment.gaugelabreference }}</span>
<span class="info-value mono">{{ machine.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'equipment') && equipment.maintenancereference">
<div class="info-row" v-if="isEnabled('maintenancereference', 'machine') && machine.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ equipment.maintenancereference }}</span>
<span class="info-value mono">{{ machine.maintenancereference }}</span>
</div>
<div class="info-row" v-if="equipment.serialnumber">
<div class="info-row" v-if="machine.serialnumber">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ equipment.serialnumber }}</span>
<span class="info-value mono">{{ machine.serialnumber }}</span>
</div>
</div>
</div>
@@ -93,43 +93,43 @@
<div class="info-list">
<div class="info-row">
<span class="info-label">Type</span>
<span class="info-value">{{ equipment.equipment?.equipmenttypename || '-' }}</span>
<span class="info-value">{{ machine.machine?.machinetypename || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ equipment.equipment?.vendorname || '-' }}</span>
<span class="info-value">{{ machine.machine?.vendorname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ equipment.equipment?.modelname || '-' }}</span>
<span class="info-value">{{ machine.machine?.modelname || '-' }}</span>
</div>
</div>
</div>
<!-- Controller Section (for CNC machines) -->
<div class="section-card" v-if="equipment.equipment?.controllervendorname || equipment.equipment?.controllermodelname">
<div class="section-card" v-if="machine.machine?.controllervendorname || machine.machine?.controllermodelname">
<h3 class="section-title">Controller</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ equipment.equipment?.controllervendorname || '-' }}</span>
<span class="info-value">{{ machine.machine?.controllervendorname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ equipment.equipment?.controllermodelname || '-' }}</span>
<span class="info-value">{{ machine.machine?.controllermodelname || '-' }}</span>
</div>
</div>
</div>
<!-- Equipment Configuration -->
<!-- Machine Configuration -->
<div class="section-card">
<h3 class="section-title">Configuration</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Requires Manual Config</span>
<span class="info-value">
<span class="feature-tag" :class="{ active: equipment.equipment?.requiresmanualconfig }">
{{ equipment.equipment?.requiresmanualconfig ? 'Yes' : 'No' }}
<span class="feature-tag" :class="{ active: machine.machine?.requiresmanualconfig }">
{{ machine.machine?.requiresmanualconfig ? 'Yes' : 'No' }}
</span>
</span>
</div>
@@ -137,20 +137,20 @@
</div>
<!-- Maintenance Section -->
<div class="section-card" v-if="equipment.equipment?.lastmaintenancedate || equipment.equipment?.nextmaintenancedate">
<div class="section-card" v-if="machine.machine?.lastmaintenancedate || machine.machine?.nextmaintenancedate">
<h3 class="section-title">Maintenance</h3>
<div class="info-list">
<div class="info-row" v-if="equipment.equipment?.lastmaintenancedate">
<div class="info-row" v-if="machine.machine?.lastmaintenancedate">
<span class="info-label">Last Maintenance</span>
<span class="info-value">{{ formatDate(equipment.equipment.lastmaintenancedate) }}</span>
<span class="info-value">{{ formatDate(machine.machine.lastmaintenancedate) }}</span>
</div>
<div class="info-row" v-if="equipment.equipment?.nextmaintenancedate">
<div class="info-row" v-if="machine.machine?.nextmaintenancedate">
<span class="info-label">Next Maintenance</span>
<span class="info-value">{{ formatDate(equipment.equipment.nextmaintenancedate) }}</span>
<span class="info-value">{{ formatDate(machine.machine.nextmaintenancedate) }}</span>
</div>
<div class="info-row" v-if="equipment.equipment?.maintenanceintervaldays">
<div class="info-row" v-if="machine.machine?.maintenanceintervaldays">
<span class="info-label">Interval</span>
<span class="info-value">{{ equipment.equipment.maintenanceintervaldays }} days</span>
<span class="info-value">{{ machine.machine.maintenanceintervaldays }} days</span>
</div>
</div>
</div>
@@ -166,19 +166,19 @@
<span class="info-label">Location</span>
<span class="info-value">
<LocationMapTooltip
v-if="equipment.mapx != null && equipment.mapy != null"
:left="equipment.mapx"
:top="equipment.mapy"
:machineName="equipment.assetnumber"
v-if="machine.mapx != null && machine.mapy != null"
:left="machine.mapx"
:top="machine.mapy"
:machineName="machine.assetnumber"
>
<span class="location-link">{{ equipment.locationname || 'On Map' }}</span>
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
</LocationMapTooltip>
<span v-else>{{ equipment.locationname || '-' }}</span>
<span v-else>{{ machine.locationname || '-' }}</span>
</span>
</div>
<div class="info-row">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ equipment.businessunitname || '-' }}</span>
<span class="info-value">{{ machine.businessunitname || '-' }}</span>
</div>
</div>
</div>
@@ -208,23 +208,26 @@
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="equipment.assetid" />
<CustomFieldsSection :assetid="machine.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="equipment.assetid" :items="warranties" />
<WarrantyPanel :assetid="machine.assetid" :items="warranties" />
<!-- All relationships (dualpath, controls, ...) -->
<AssetRelationships v-if="machine.assetid" :assetId="machine.assetid" />
<!-- Notes -->
<div class="section-card" v-if="equipment.notes">
<div class="section-card" v-if="machine.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ equipment.notes }}</p>
<p class="notes-text">{{ machine.notes }}</p>
</div>
</div>
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDate(equipment.createddate) }}<template v-if="equipment.createdby"> by {{ equipment.createdby }}</template></span>
<span>Modified {{ formatDate(equipment.modifieddate) }}<template v-if="equipment.modifiedby"> by {{ equipment.modifiedby }}</template></span>
<span>Created {{ formatDate(machine.createddate) }}<template v-if="machine.createdby"> by {{ machine.createdby }}</template></span>
<span>Modified {{ formatDate(machine.modifieddate) }}<template v-if="machine.modifiedby"> by {{ machine.modifiedby }}</template></span>
</div>
</template>
@@ -237,10 +240,11 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { equipmentApi, assetsApi } from '../../api'
import { machinesApi, assetsApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import AssetRelationships from '../../components/AssetRelationships.vue'
import { useWarrantyBadge } from '../../composables/warrantyBadge'
import { useIdentifierFlags } from '../../composables/identifierSettings'
@@ -248,8 +252,8 @@ const route = useRoute()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
const equipment = ref(null)
const { warranties, heroWarranty, warrantyDate } = useWarrantyBadge(() => equipment.value?.assetid)
const machine = ref(null)
const { warranties, heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.assetid)
const relationships = ref({ incoming: [], outgoing: [] })
// type name is data, sites may seed 'controls' or 'Controls' - compare folded
@@ -258,10 +262,10 @@ function isControls(rel) {
}
const controllingPc = computed(() => {
// For equipment, find a related computer in any "Controls" relationship
// Check both incoming (computer controls this) and outgoing (legacy data may have equipment -> computer)
// For a machine, find a related computer in any "Controls" relationship
// Check both incoming (computer controls this) and outgoing (legacy data may have machine -> computer)
// First check incoming - computer as source controlling this equipment
// First check incoming - computer as source controlling this machine
for (const rel of relationships.value.incoming || []) {
if (rel.sourceasset?.assettypename === 'computer' && isControls(rel)) {
return {
@@ -271,7 +275,7 @@ const controllingPc = computed(() => {
}
}
// Also check outgoing - legacy data may have equipment -> computer Controls relationships
// Also check outgoing - legacy data may have machine -> computer Controls relationships
for (const rel of relationships.value.outgoing || []) {
if (rel.targetasset?.assettypename === 'computer' && isControls(rel)) {
return {
@@ -286,20 +290,20 @@ const controllingPc = computed(() => {
onMounted(async () => {
try {
const response = await equipmentApi.get(route.params.id)
equipment.value = response.data.data
const response = await machinesApi.get(route.params.id)
machine.value = response.data.data
// Load relationships using asset ID
if (equipment.value?.assetid) {
if (machine.value?.assetid) {
try {
const relResponse = await assetsApi.getRelationships(equipment.value.assetid)
const relResponse = await assetsApi.getRelationships(machine.value.assetid)
relationships.value = relResponse.data.data || { incoming: [], outgoing: [] }
} catch (e) {
console.log('Relationships not available')
}
}
} catch (error) {
console.error('Error loading equipment:', error)
console.error('Error loading machine:', error)
} finally {
loading.value = false
}

View File

@@ -7,7 +7,7 @@
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveEquipment">
<form v-else @submit.prevent="saveMachine">
<!-- Identity Section -->
<h3 class="form-section-title">Identity</h3>
<div class="form-row">
@@ -34,8 +34,8 @@
</div>
</div>
<div class="form-row" v-if="isEnabled('gaugelabreference', 'equipment') || isEnabled('maintenancereference', 'equipment')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'equipment')">
<div class="form-row" v-if="isEnabled('gaugelabreference', 'machine') || isEnabled('maintenancereference', 'machine')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'machine')">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input
id="gaugelabreference"
@@ -46,7 +46,7 @@
<small class="form-help">Authoritative gauge lab asset reference (if tracked)</small>
</div>
<div class="form-group" v-if="isEnabled('maintenancereference', 'equipment')">
<div class="form-group" v-if="isEnabled('maintenancereference', 'machine')">
<label for="maintenancereference">Maintenance Reference</label>
<input
id="maintenancereference"
@@ -88,23 +88,23 @@
</div>
</div>
<!-- Equipment Hardware Section -->
<!-- Machine Hardware Section -->
<h3 class="form-section-title">Machine Hardware</h3>
<div class="form-row">
<div class="form-group">
<label for="equipmenttypeid">Equipment Type</label>
<label for="machinetypeid">Machine Type</label>
<select
id="equipmenttypeid"
v-model="form.equipmenttypeid"
id="machinetypeid"
v-model="form.machinetypeid"
class="form-control"
>
<option value="">Select type...</option>
<option
v-for="et in equipmentTypes"
:key="et.equipmenttypeid"
:value="et.equipmenttypeid"
v-for="mt in machineTypes"
:key="mt.machinetypeid"
:value="mt.machinetypeid"
>
{{ et.equipmenttype }}
{{ mt.machinetype }}
</option>
</select>
</div>
@@ -294,7 +294,7 @@
{{ pc.assetnumber }}{{ pc.name ? ` (${pc.name})` : '' }}
</option>
</select>
<small class="form-help">Select the PC that controls this equipment</small>
<small class="form-help">Select the PC that controls this machine</small>
</div>
<div class="form-group" v-if="controllingPcId">
@@ -312,7 +312,7 @@
{{ rt.relationshiptype }}
</option>
</select>
<small class="form-help">How the PC connects to this equipment</small>
<small class="form-help">How the PC connects to this machine</small>
</div>
</div>
@@ -328,8 +328,8 @@
></textarea>
</div>
<!-- Site-defined custom fields for equipment -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="EQUIPMENT_ASSETTYPEID" :assetid="currentAssetId" />
<!-- Site-defined custom fields for machines -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="MACHINE_ASSETTYPEID" :assetid="currentAssetId" />
<div v-if="error" class="error-message">{{ error }}</div>
@@ -347,7 +347,7 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { equipmentApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi } from '../../api'
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
@@ -362,8 +362,8 @@ const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for equipment (see /api/assets/types).
const EQUIPMENT_ASSETTYPEID = 1
// Seeded asset-type id for machines (see /api/assets/types).
const MACHINE_ASSETTYPEID = 1
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
@@ -380,7 +380,7 @@ const form = ref({
maintenancereference: '',
serialnumber: '',
statusid: 1,
equipmenttypeid: '',
machinetypeid: '',
vendorid: '',
modelnumberid: '',
controllervendorid: '',
@@ -394,7 +394,7 @@ const form = ref({
mapy: null
})
const equipmentTypes = ref([])
const machineTypes = ref([])
const statuses = ref([])
const vendors = ref([])
const locations = ref([])
@@ -405,9 +405,9 @@ const relationshipTypes = ref([])
const controllingPcId = ref(null)
const relationshipTypeId = ref(null)
const existingRelationshipId = ref(null)
const currentEquipment = ref(null)
const currentMachine = ref(null)
// Filter models by selected equipment vendor
// Filter models by selected machine vendor
const filteredModels = computed(() => {
if (!form.value.vendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.vendorid)
@@ -443,7 +443,7 @@ onMounted(async () => {
try {
// Load reference data in parallel
const [typesRes, statusRes, vendorRes, locRes, allModels, buRes, pcsRes, relTypesRes] = await Promise.all([
equipmentApi.types.list(),
machinesApi.types.list(),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 500 }),
locationsApi.list({ perpage: 500 }),
@@ -453,7 +453,7 @@ onMounted(async () => {
assetsApi.types.list() // Used for relationship types, will fix below
])
equipmentTypes.value = typesRes.data.data || []
machineTypes.value = typesRes.data.data || []
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
locations.value = locRes.data.data || []
@@ -479,12 +479,12 @@ onMounted(async () => {
relationshipTypeId.value = controlsType.relationshiptypeid
}
// Load equipment if editing
// Load machine if editing
if (isEdit.value) {
const response = await equipmentApi.get(route.params.id)
const response = await machinesApi.get(route.params.id)
const data = response.data.data
currentAssetId.value = data.assetid || null
currentEquipment.value = data
currentMachine.value = data
form.value = {
assetnumber: data.assetnumber || '',
@@ -493,15 +493,15 @@ onMounted(async () => {
maintenancereference: data.maintenancereference || '',
serialnumber: data.serialnumber || '',
statusid: data.statusid || 1,
equipmenttypeid: data.equipment?.equipmenttypeid || '',
vendorid: data.equipment?.vendorid || '',
modelnumberid: data.equipment?.modelnumberid || '',
controllervendorid: data.equipment?.controllervendorid || '',
controllermodelid: data.equipment?.controllermodelid || '',
machinetypeid: data.machine?.machinetypeid || '',
vendorid: data.machine?.vendorid || '',
modelnumberid: data.machine?.modelnumberid || '',
controllervendorid: data.machine?.controllervendorid || '',
controllermodelid: data.machine?.controllermodelid || '',
locationid: data.locationid || '',
businessunitid: data.businessunitid || '',
requiresmanualconfig: data.equipment?.requiresmanualconfig || false,
islocationonly: data.equipment?.islocationonly || false,
requiresmanualconfig: data.machine?.requiresmanualconfig || false,
islocationonly: data.machine?.islocationonly || false,
notes: data.notes || '',
mapx: data.mapx ?? null,
mapy: data.mapy ?? null
@@ -553,7 +553,7 @@ function clearMapPosition() {
tempMapPosition.value = null
}
async function saveEquipment() {
async function saveMachine() {
error.value = ''
saving.value = true
@@ -565,7 +565,7 @@ async function saveEquipment() {
maintenancereference: form.value.maintenancereference || null,
serialnumber: form.value.serialnumber || null,
statusid: form.value.statusid || 1,
equipmenttypeid: form.value.equipmenttypeid || null,
machinetypeid: form.value.machinetypeid || null,
vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null,
controllervendorid: form.value.controllervendorid || null,
@@ -579,17 +579,17 @@ async function saveEquipment() {
mapy: form.value.mapy
}
let savedEquipment
let savedMachine
let assetId
if (isEdit.value) {
const response = await equipmentApi.update(route.params.id, data)
savedEquipment = response.data.data
assetId = savedEquipment.assetid
const response = await machinesApi.update(route.params.id, data)
savedMachine = response.data.data
assetId = savedMachine.assetid
} else {
const response = await equipmentApi.create(data)
savedEquipment = response.data.data
assetId = savedEquipment.assetid
const response = await machinesApi.create(data)
savedMachine = response.data.data
assetId = savedMachine.assetid
}
// Handle relationship (controlling PC)
@@ -604,10 +604,10 @@ async function saveEquipment() {
}
}
router.push(`/machines/${savedEquipment.equipment?.equipmentid || route.params.id}`)
router.push(`/machines/${savedMachine.machine?.machineid || route.params.id}`)
} catch (err) {
console.error('Error saving equipment:', err)
error.value = apiError(err, 'Failed to save equipment')
console.error('Error saving machine:', err)
error.value = apiError(err, 'Failed to save machine')
} finally {
saving.value = false
}
@@ -632,7 +632,7 @@ async function saveRelationship(assetId) {
await assetsApi.deleteRelationship(existingRelationshipId.value)
}
// Create new relationship (PC controls Equipment, so PC is source, Equipment is target)
// Create new relationship (PC controls Machine, so PC is source, Machine is target)
await assetsApi.createRelationship({
sourceassetid: controllingPcId.value,
targetassetid: assetId,

View File

@@ -35,12 +35,12 @@
</tr>
</thead>
<tbody>
<tr v-for="item in equipment" :key="item.assetid">
<tr v-for="item in machines" :key="item.assetid">
<td>{{ item.assetnumber }}</td>
<td>{{ item.name || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.equipment?.equipmenttypename || '-' }}</td>
<td>{{ item.equipment?.vendorname || '-' }}</td>
<td>{{ item.machine?.machinetypename || '-' }}</td>
<td>{{ item.machine?.vendorname || '-' }}</td>
<td>
<span class="badge" :style="colorStyle(item.statuscolor)">
{{ item.statusname || 'Unknown' }}
@@ -49,16 +49,16 @@
<td>{{ item.locationname || '-' }}</td>
<td class="actions">
<router-link
:to="`/machines/${item.equipment?.equipmentid || item.assetid}`"
:to="`/machines/${item.machine?.machineid || item.assetid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="equipment.length === 0">
<tr v-if="machines.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No equipment found
No machines found
</td>
</tr>
</tbody>
@@ -82,10 +82,10 @@
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { equipmentApi } from '../../api'
import { machinesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const equipment = ref([])
const machines = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
@@ -95,10 +95,10 @@ const perPage = ref(20)
let searchTimeout = null
onMounted(() => {
loadEquipment()
loadMachines()
})
async function loadEquipment() {
async function loadMachines() {
loading.value = true
try {
const params = {
@@ -107,11 +107,11 @@ async function loadEquipment() {
}
if (search.value) params.search = search.value
const response = await equipmentApi.list(params)
equipment.value = response.data.data || []
const response = await machinesApi.list(params)
machines.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading equipment:', error)
console.error('Error loading machines:', error)
} finally {
loading.value = false
}
@@ -121,19 +121,19 @@ function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadEquipment()
loadMachines()
}, 300)
}
function goToPage(p) {
page.value = p
loadEquipment()
loadMachines()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadEquipment()
loadMachines()
}
</script>

View File

@@ -131,6 +131,9 @@
<!-- Warranty -->
<WarrantyPanel :assetid="tool.assetid" :items="warranties" />
<!-- All relationships (partof, connectedto, ...) -->
<AssetRelationships v-if="tool.assetid" :assetId="tool.assetid" />
<!-- Notes -->
<div class="section-card" v-if="tool.notes">
<h3 class="section-title">Notes</h3>
@@ -159,6 +162,7 @@ import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '../../api'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import AssetRelationships from '../../components/AssetRelationships.vue'
import { useWarrantyBadge } from '../../composables/warrantyBadge'
const route = useRoute()

View File

@@ -180,9 +180,9 @@
</div>
</div>
<!-- Controlled Equipment -->
<!-- Controlled Machines -->
<div class="section-card" v-if="controlledEquipment.length > 0">
<h3 class="section-title">Controlled Equipment</h3>
<h3 class="section-title">Controlled Machines</h3>
<div class="equipment-list">
<router-link
v-for="item in controlledEquipment"
@@ -229,6 +229,9 @@
<!-- Warranty -->
<WarrantyPanel :assetid="computer.assetid" :items="warranties" />
<!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetId="computer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">
<h3 class="section-title">Notes</h3>
@@ -259,6 +262,7 @@ import { useWarrantyBadge } from '../../composables/warrantyBadge'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import AssetRelationships from '../../components/AssetRelationships.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute()
@@ -283,7 +287,7 @@ const controlledEquipment = computed(() => {
// Check outgoing - computer controls equipment
for (const rel of relationships.value.outgoing || []) {
if (rel.targetasset?.assettypename === 'equipment' && isControls(rel)) {
if (rel.targetasset?.assettypename === 'machine' && isControls(rel)) {
items.push({
...rel.targetasset,
relationshipid: rel.relationshipid,
@@ -294,7 +298,7 @@ const controlledEquipment = computed(() => {
// Also check incoming - legacy data may have equipment -> computer Controls relationships
for (const rel of relationships.value.incoming || []) {
if (rel.sourceasset?.assettypename === 'equipment' && isControls(rel)) {
if (rel.sourceasset?.assettypename === 'machine' && isControls(rel)) {
items.push({
...rel.sourceasset,
relationshipid: rel.relationshipid,

View File

@@ -2,7 +2,7 @@
<div>
<button class="print-btn" @click="print" v-if="!loading">Print Badge</button>
<div v-if="loading" class="loading-msg">Loading...</div>
<div v-else-if="equipment" class="badge-container">
<div v-else-if="machine" class="badge-container">
<div class="model-name">{{ modelName }}</div>
<img
v-if="isInspection"
@@ -18,41 +18,41 @@
/>
<div class="barcode-container">
<svg ref="barcodeEl"></svg>
<div class="machine-number">*{{ equipment.assetnumber }}*</div>
<div class="machine-number">*{{ machine.assetnumber }}*</div>
</div>
</div>
<div v-else class="error-msg">Equipment not found</div>
<div v-else class="error-msg">Machine not found</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { equipmentApi } from '../../api'
import { machinesApi } from '../../api'
import { getBadgeLogo } from '@/utils/siteSettings'
import JsBarcode from 'jsbarcode'
const route = useRoute()
const loading = ref(true)
const equipment = ref(null)
const machine = ref(null)
const barcodeEl = ref(null)
const geLogo = ref('/ge-aerospace-logo.svg')
const isInspection = computed(() => {
if (!equipment.value) return false
return equipment.value.assetnumber?.startsWith('06')
if (!machine.value) return false
return machine.value.assetnumber?.startsWith('06')
})
const modelName = computed(() => {
if (!equipment.value) return ''
if (!machine.value) return ''
if (isInspection.value) return 'Inspection'
return equipment.value.equipment?.modelname || ''
return machine.value.machine?.modelname || ''
})
const imageUrl = computed(() => {
if (!equipment.value) return null
const img = equipment.value.equipment?.imageurl
if (!machine.value) return null
const img = machine.value.machine?.imageurl
if (img) return img.startsWith('/') ? img : `/images/models/machines/${img}`
return null
})
@@ -60,10 +60,10 @@ const imageUrl = computed(() => {
onMounted(async () => {
getBadgeLogo().then(logo => { geLogo.value = logo })
try {
const response = await equipmentApi.get(route.params.id)
equipment.value = response.data.data
const response = await machinesApi.get(route.params.id)
machine.value = response.data.data
} catch (error) {
console.error('Error loading equipment:', error)
console.error('Error loading machine:', error)
} finally {
loading.value = false
await nextTick()
@@ -72,9 +72,9 @@ onMounted(async () => {
})
function generateBarcode() {
if (!barcodeEl.value || !equipment.value) return
if (!barcodeEl.value || !machine.value) return
try {
JsBarcode(barcodeEl.value, equipment.value.assetnumber, {
JsBarcode(barcodeEl.value, machine.value.assetnumber, {
format: 'CODE39',
displayValue: false,
width: 2,

View File

@@ -127,6 +127,9 @@
<!-- Warranty -->
<WarrantyPanel :assetid="printer.assetid" :items="warranties" />
<!-- All relationships (defaultprinter, connectedto, ...) -->
<AssetRelationships v-if="printer.assetid" :assetId="printer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="printer.notes">
<h3 class="section-title">Notes</h3>
@@ -267,6 +270,7 @@ import { printersApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import AssetRelationships from '../../components/AssetRelationships.vue'
import { useWarrantyBadge } from '../../composables/warrantyBadge'
import { useIdentifierFlags } from '../../composables/identifierSettings'

View File

@@ -107,8 +107,8 @@
<div v-if="loading" class="loading">Loading report...</div>
<div v-else-if="reportData">
<!-- Equipment by Type -->
<div v-if="currentReport.id === 'equipment-by-type'" class="report-content">
<!-- Machines by Type -->
<div v-if="currentReport.id === 'machines-by-type'" class="report-content">
<p class="report-summary">Total: {{ reportData.total }}</p>
<table>
<thead>
@@ -119,8 +119,8 @@
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.equipmenttype">
<td>{{ item.equipmenttype }}</td>
<tr v-for="item in reportData.data" :key="item.machinetype">
<td>{{ item.machinetype }}</td>
<td>{{ item.description }}</td>
<td>{{ item.count }}</td>
</tr>
@@ -235,7 +235,7 @@ const categoryOrder = ['inventory', 'compliance', 'usage']
// server-side filters each inline report accepts (query params on its endpoint)
const reportFilterFields = {
'equipment-by-type': ['businessunit'],
'machines-by-type': ['businessunit'],
'assets-by-status': ['assettype', 'businessunit'],
'asset-inventory': ['businessunit', 'location'],
'kb-popularity': ['limit'],
@@ -388,8 +388,8 @@ async function runReport(report) {
try {
let response
switch (report.id) {
case 'equipment-by-type':
response = await reportsApi.equipmentByType(params)
case 'machines-by-type':
response = await reportsApi.machinesByType(params)
break
case 'assets-by-status':
response = await reportsApi.assetsByStatus(params)

View File

@@ -70,7 +70,7 @@ const bucketOrder = [
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
function cardStyle(color) { return { borderTop: `3px solid ${color}` } }
function assetLink(a) {
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', equipment: '/machines/' }
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/' }
return (map[a.assettypename] || '/assets/') + a.assetid
}

View File

@@ -128,7 +128,7 @@ const filterAction = ref('')
const filterEntity = ref('')
const selectedLog = ref(null)
const entityTypes = ['Asset', 'Printer', 'Computer', 'Equipment', 'Network', 'Setting', 'User', 'Application', 'KnowledgeBase']
const entityTypes = ['Asset', 'Printer', 'Computer', 'Machine', 'Network', 'Setting', 'User', 'Application', 'KnowledgeBase']
const totalPages = computed(() => Math.ceil(total.value / perpage))
@@ -183,7 +183,7 @@ function getEntityLink(log) {
switch (type) {
case 'printer': return `/printers/${log.entityid}`
case 'computer': return `/pcs/${log.entityid}`
case 'equipment': return `/equipment/${log.entityid}`
case 'machine': return `/machines/${log.entityid}`
case 'asset': return `/assets/${log.entityid}`
case 'application': return `/applications/${log.entityid}`
case 'knowledgebase': return `/kb/${log.entityid}`

View File

@@ -1,145 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Equipment Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Equipment Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Equipment Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.equipmenttypeid">
<td>{{ t.equipmenttype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No equipment types found</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Equipment Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Equipment Type *</label>
<input v-model="form.equipmenttype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { equipmentApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
const toast = useToast()
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ equipmenttype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await equipmentApi.types.list({ perpage: 200, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading equipment types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { equipmenttype: item.equipmenttype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { equipmenttype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await equipmentApi.types.update(editing.value.equipmenttypeid, form.value)
} else {
await equipmentApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save')
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete equipment type "${t.equipmenttype}"?`)) return
try {
await equipmentApi.types.remove(t.equipmenttypeid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
</script>

View File

@@ -2,188 +2,101 @@
<div>
<div class="page-header">
<h2>Machine Types</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Type</button>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Machine Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Machine Type</th>
<th>Category</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="mt in machineTypes" :key="mt.machinetypeid">
<td>{{ mt.machinetype }}</td>
<td>
<span class="badge" :class="getCategoryClass(mt.category)">
{{ mt.category }}
</span>
</td>
<td class="cell-truncate" :title="mt.description">{{ mt.description || '-' }}</td>
<tr v-for="t in visibleItems" :key="t.machinetypeid">
<td>{{ t.machinetype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(mt)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(mt)"
>
Delete
</button>
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="machineTypes.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No machine types found
</td>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No machine types found</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editingType ? 'Edit Machine Type' : 'Add Machine Type' }}</h3>
</div>
<form @submit.prevent="saveType">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Machine Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="machinetype">Type Name *</label>
<input
id="machinetype"
v-model="form.machinetype"
type="text"
class="form-control"
required
/>
<label>Machine Type *</label>
<input v-model="form.machinetype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label for="category">Category *</label>
<select
id="category"
v-model="form.category"
class="form-control"
required
>
<option value="">Select category...</option>
<option value="Equipment">Equipment</option>
<option value="PC">PC</option>
<option value="Network">Network</option>
<option value="Printer">Printer</option>
</select>
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
v-model="form.description"
class="form-control"
rows="3"
></textarea>
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header">
<h3>Delete Machine Type</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ typeToDelete?.machinetype }}</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
This may affect machines using this type.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteType">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { machinetypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { ref, computed, onMounted } from 'vue'
import { machinesApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
const toast = useToast()
const machineTypes = ref([])
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingType = ref(null)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ machinetype: '', description: '', color: '', isactive: true })
const showDeleteModal = ref(false)
const typeToDelete = ref(null)
onMounted(loadData)
const form = ref({
machinetype: '',
category: '',
description: ''
})
onMounted(() => {
loadTypes()
})
async function loadTypes() {
async function loadData() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
const response = await machinetypesApi.list(params)
machineTypes.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
const response = await machinesApi.types.list({ perpage: 200, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading machine types:', err)
} finally {
@@ -191,87 +104,42 @@ async function loadTypes() {
}
}
function goToPage(p) {
page.value = p
loadTypes()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadTypes()
}
function openModal(mt = null) {
editingType.value = mt
if (mt) {
form.value = {
machinetype: mt.machinetype || '',
category: mt.category || '',
description: mt.description || ''
}
} else {
form.value = {
machinetype: '',
category: '',
description: ''
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { machinetype: item.machinetype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { machinetype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingType.value = null
}
function closeModal() { showModal.value = false; editing.value = null }
async function saveType() {
async function save() {
error.value = ''
saving.value = true
try {
if (editingType.value) {
await machinetypesApi.update(editingType.value.machinetypeid, form.value)
if (editing.value) {
await machinesApi.types.update(editing.value.machinetypeid, form.value)
} else {
await machinetypesApi.create(form.value)
await machinesApi.types.create(form.value)
}
closeModal()
loadTypes()
loadData()
} catch (err) {
console.error('Error saving machine type:', err)
error.value = apiError(err, 'Failed to save machine type')
error.value = apiError(err, 'Failed to save')
} finally {
saving.value = false
}
}
function confirmDelete(mt) {
typeToDelete.value = mt
showDeleteModal.value = true
}
async function deleteType() {
async function deleteType(t) {
if (!confirm(`Delete machine type "${t.machinetype}"?`)) return
try {
await machinetypesApi.delete(typeToDelete.value.machinetypeid)
showDeleteModal.value = false
typeToDelete.value = null
loadTypes()
await machinesApi.types.remove(t.machinetypeid)
loadData()
} catch (err) {
console.error('Error deleting machine type:', err)
toast.error('Failed to delete machine type')
toast.error(apiError(err, 'Failed to delete'))
}
}
function getCategoryClass(category) {
if (!category) return 'badge-info'
const c = category.toLowerCase()
if (c === 'equipment') return 'badge-info'
if (c === 'pc') return 'badge-success'
if (c === 'network') return 'badge-warning'
if (c === 'printer') return 'badge-primary'
return 'badge-info'
}
</script>
<!-- Uses global styles from style.css -->

View File

@@ -0,0 +1,277 @@
<template>
<div>
<div class="page-header">
<h2>Model Types</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Model Type</th>
<th>Category</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="mt in modelTypes" :key="mt.modeltypeid">
<td>{{ mt.modeltype }}</td>
<td>
<span class="badge" :class="getCategoryClass(mt.category)">
{{ mt.category }}
</span>
</td>
<td class="cell-truncate" :title="mt.description">{{ mt.description || '-' }}</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(mt)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(mt)"
>
Delete
</button>
</td>
</tr>
<tr v-if="modelTypes.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No model types found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editingType ? 'Edit Model Type' : 'Add Model Type' }}</h3>
</div>
<form @submit.prevent="saveType">
<div class="modal-body">
<div class="form-group">
<label for="modeltype">Type Name *</label>
<input
id="modeltype"
v-model="form.modeltype"
type="text"
class="form-control"
required
/>
</div>
<div class="form-group">
<label for="category">Category *</label>
<select
id="category"
v-model="form.category"
class="form-control"
required
>
<option value="">Select category...</option>
<option value="Equipment">Equipment</option>
<option value="PC">PC</option>
<option value="Network">Network</option>
<option value="Printer">Printer</option>
</select>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
v-model="form.description"
class="form-control"
rows="3"
></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header">
<h3>Delete Model Type</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ typeToDelete?.modeltype }}</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
This may affect models using this type.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteType">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { modeltypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
const toast = useToast()
const modelTypes = ref([])
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingType = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const typeToDelete = ref(null)
const form = ref({
modeltype: '',
category: '',
description: ''
})
onMounted(() => {
loadTypes()
})
async function loadTypes() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
const response = await modeltypesApi.list(params)
modelTypes.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading model types:', err)
} finally {
loading.value = false
}
}
function goToPage(p) {
page.value = p
loadTypes()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadTypes()
}
function openModal(mt = null) {
editingType.value = mt
if (mt) {
form.value = {
modeltype: mt.modeltype || '',
category: mt.category || '',
description: mt.description || ''
}
} else {
form.value = {
modeltype: '',
category: '',
description: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingType.value = null
}
async function saveType() {
error.value = ''
saving.value = true
try {
if (editingType.value) {
await modeltypesApi.update(editingType.value.modeltypeid, form.value)
} else {
await modeltypesApi.create(form.value)
}
closeModal()
loadTypes()
} catch (err) {
console.error('Error saving model type:', err)
error.value = apiError(err, 'Failed to save model type')
} finally {
saving.value = false
}
}
function confirmDelete(mt) {
typeToDelete.value = mt
showDeleteModal.value = true
}
async function deleteType() {
try {
await modeltypesApi.delete(typeToDelete.value.modeltypeid)
showDeleteModal.value = false
typeToDelete.value = null
loadTypes()
} catch (err) {
console.error('Error deleting model type:', err)
toast.error('Failed to delete model type')
}
}
function getCategoryClass(category) {
if (!category) return 'badge-info'
const c = category.toLowerCase()
if (c === 'equipment') return 'badge-info'
if (c === 'pc') return 'badge-success'
if (c === 'network') return 'badge-warning'
if (c === 'printer') return 'badge-primary'
return 'badge-info'
}
</script>
<!-- Uses global styles from style.css -->

View File

@@ -44,7 +44,7 @@
<small v-if="m.description" class="text-muted">{{ m.description }}</small>
</td>
<td>{{ m.vendor || '-' }}</td>
<td>{{ m.machinetype || '-' }}</td>
<td>{{ m.modeltype || '-' }}</td>
<td>
<a v-if="m.documentationurl" :href="m.documentationurl" target="_blank" class="btn btn-sm btn-link">
View Docs
@@ -108,11 +108,11 @@
<div class="form-row">
<div class="form-group">
<label for="machinetypeid">Machine Type</label>
<select id="machinetypeid" v-model="form.machinetypeid" class="form-control">
<label for="modeltypeid">Model Type</label>
<select id="modeltypeid" v-model="form.modeltypeid" class="form-control">
<option value="">Select type...</option>
<option v-for="mt in machineTypes" :key="mt.machinetypeid" :value="mt.machinetypeid">
{{ mt.machinetype }}
<option v-for="mt in modelTypes" :key="mt.modeltypeid" :value="mt.modeltypeid">
{{ mt.modeltype }}
</option>
</select>
</div>
@@ -181,7 +181,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { modelsApi, vendorsApi, machinetypesApi } from '../../api'
import { modelsApi, vendorsApi, modeltypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
@@ -189,7 +189,7 @@ const toast = useToast()
const models = ref([])
const vendors = ref([])
const machineTypes = ref([])
const modelTypes = ref([])
const loading = ref(true)
const search = ref('')
const vendorFilter = ref('')
@@ -208,7 +208,7 @@ const modelToDelete = ref(null)
const form = ref({
modelnumber: '',
vendorid: '',
machinetypeid: '',
modeltypeid: '',
description: '',
documentationurl: '',
imageurl: '',
@@ -221,7 +221,7 @@ onMounted(async () => {
await Promise.all([
loadModels(),
loadVendors(),
loadMachineTypes()
loadModelTypes()
])
})
@@ -251,12 +251,12 @@ async function loadVendors() {
}
}
async function loadMachineTypes() {
async function loadModelTypes() {
try {
const response = await machinetypesApi.list({ perpage: 100 })
machineTypes.value = response.data.data || []
const response = await modeltypesApi.list({ perpage: 100 })
modelTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading machine types:', err)
console.error('Error loading model types:', err)
}
}
@@ -285,7 +285,7 @@ function openModal(m = null) {
form.value = {
modelnumber: m.modelnumber || '',
vendorid: m.vendorid || '',
machinetypeid: m.machinetypeid || '',
modeltypeid: m.modeltypeid || '',
description: m.description || '',
documentationurl: m.documentationurl || '',
imageurl: m.imageurl || '',
@@ -295,7 +295,7 @@ function openModal(m = null) {
form.value = {
modelnumber: '',
vendorid: '',
machinetypeid: '',
modeltypeid: '',
description: '',
documentationurl: '',
imageurl: '',
@@ -318,7 +318,7 @@ async function saveModel() {
try {
const data = { ...form.value }
if (!data.vendorid) data.vendorid = null
if (!data.machinetypeid) data.machinetypeid = null
if (!data.modeltypeid) data.modeltypeid = null
if (editingModel.value) {
await modelsApi.update(editingModel.value.modelnumberid, data)

View File

@@ -1037,9 +1037,9 @@ const brandingLogos = [
{ kind: 'qr', key: 'qr_logo', label: 'QR overlay logo', accept: 'image/*',
placeholder: '/ge-monogram.svg',
hint: 'Logo overlaid on printed QR codes. Leave blank for no overlay.' },
{ kind: 'badge', key: 'badge_logo', label: 'Equipment badge logo', accept: 'image/*',
{ kind: 'badge', key: 'badge_logo', label: 'Machine badge logo', accept: 'image/*',
placeholder: '/ge-aerospace-logo.svg',
hint: 'Logo printed on equipment badges. Upload an image or type a path/URL.' },
hint: 'Logo printed on machine badges. Upload an image or type a path/URL.' },
{ kind: 'favicon', key: 'site_favicon', label: 'Favicon', accept: 'image/*,.ico',
placeholder: '(blank = shipped /favicon.svg)',
hint: 'Browser-tab icon. Leave blank to use the shipped favicon.' },
@@ -1053,7 +1053,7 @@ const identifierRows = [
{ name: 'fqdn', label: 'FQDN / Hostname' }
]
const assetTypeCols = [
{ key: 'equipment', label: 'Equipment' },
{ key: 'machine', label: 'Machine' },
{ key: 'computer', label: 'PC' },
{ key: 'printer', label: 'Printer' },
{ key: 'network_device', label: 'Network' }
@@ -1075,7 +1075,7 @@ const searchDomains = [
{ key: 'application', label: 'Applications' },
{ key: 'knowledgebase', label: 'Knowledge Base' },
{ key: 'employee', label: 'Employees' },
{ key: 'equipment', label: 'Equipment' },
{ key: 'machine', label: 'Machines' },
{ key: 'computer', label: 'PCs' },
{ key: 'printer', label: 'Printers' },
{ key: 'network_device', label: 'Network Devices' },
@@ -1170,7 +1170,7 @@ async function loadSettings() {
for (const setting of data.data) {
if (setting.key in settings) {
settings[setting.key] = setting.value
} else if (/^identifier_.+_(equipment|computer|printer|network_device)_enabled$/.test(setting.key)) {
} else if (/^identifier_.+_(machine|computer|printer|network_device)_enabled$/.test(setting.key)) {
identifierMatrix[setting.key] = setting.value !== false
} else if (/^search_.+_enabled$/.test(setting.key)) {
searchMatrix[setting.key] = setting.value !== false

View File

@@ -17,6 +17,7 @@ export const settingsGroups = [
cards: [
{ to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' },
{ to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' },
{ to: '/settings/modeltypes', icon: Monitor, title: 'Model Types', description: 'Manage model type categories' },
{ to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' },
{ to: '/settings/relationshiptypes', icon: Link, title: 'Relationship Types', description: 'Manage asset relationship types (Controls, Contains...) + colors' },
{ to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' },
@@ -42,8 +43,7 @@ export const settingsGroups = [
{
title: 'Machines',
cards: [
{ to: '/settings/equipmenttypes', icon: Wrench, title: 'Equipment Types', description: 'Manage machine subtypes + map colors' },
{ to: '/settings/machinetypes', icon: Monitor, title: 'Machine Types', description: 'Manage machine type categories' },
{ to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' },
],
},
{

View File

@@ -208,7 +208,7 @@ function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
// Route to the right detail page by asset type.
function assetLink(a) {
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', equipment: '/machines/' }
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/' }
const base = map[a.assettypename] || '/assets/'
return base + a.assetid
}

View File

@@ -0,0 +1,205 @@
"""Equipment -> machines rename, core half
Part 1 of the approved equipment -> machines rename (the plugin half lives in
plugins/machines/migrations/versions/0002_rename_from_equipment.py):
1. machinetypes -> modeltypes (+ machinetypeid -> modeltypeid,
machinetype -> modeltype, models.machinetypeid -> models.modeltypeid).
The table types the vendor MODELS catalog, not machine instances, so
the new name is role-accurate and frees "machinetypes" for the plugin.
2. Data flips: assettypes 'equipment' -> 'machine' (incl pluginname and
tablename), auditlogs.entitytype 'Equipment' -> 'Machine', settings keys
identifier_*_equipment_enabled -> identifier_*_machine_enabled,
search_equipment_enabled -> search_machine_enabled, and permission
rows equipment.* -> machines.* (role links survive).
3. alembic_version_equipment -> alembic_version_machines (if present) so
the renamed plugin's migration chain resumes seamlessly.
Idempotent via inspector guards, following the 7c01 pattern. MySQL is the
only dialect the core chain runs against in practice.
Revision ID: 7d17_machines_rename
Revises: 7d16_directoryemployees
Create Date: 2026-07-11
"""
from alembic import op
import sqlalchemy as sa
revision = '7d17_machines_rename'
down_revision = '7d16_directoryemployees'
branch_labels = None
depends_on = None
def _fk_names(insp, table, referred_table):
# find FK constraint names on table pointing at referred_table
return [fk['name'] for fk in insp.get_foreign_keys(table)
if fk.get('referred_table') == referred_table and fk.get('name')]
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
tables = set(insp.get_table_names())
# -- 1. machinetypes -> modeltypes ------------------------------------
if 'machinetypes' in tables and 'modeltypes' not in tables:
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0")
# drop the models -> machinetypes FK and its index before renaming
for name in _fk_names(insp, 'models', 'machinetypes'):
bind.exec_driver_sql(f"ALTER TABLE models DROP FOREIGN KEY {name}")
model_indexes = {ix['name'] for ix in insp.get_indexes('models')}
if 'machinetypeid' in model_indexes:
bind.exec_driver_sql("ALTER TABLE models DROP KEY machinetypeid")
bind.exec_driver_sql("RENAME TABLE machinetypes TO modeltypes")
bind.exec_driver_sql(
"ALTER TABLE modeltypes "
"CHANGE machinetypeid modeltypeid INT NOT NULL AUTO_INCREMENT, "
"CHANGE machinetype modeltype VARCHAR(100) NOT NULL, "
"DROP KEY machinetype, "
"ADD UNIQUE KEY modeltype (modeltype)"
)
bind.exec_driver_sql(
"ALTER TABLE models "
"CHANGE machinetypeid modeltypeid INT NULL, "
"ADD KEY modeltypeid (modeltypeid), "
"ADD CONSTRAINT fk_models_modeltypeid "
"FOREIGN KEY (modeltypeid) REFERENCES modeltypes (modeltypeid)"
)
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1")
# -- 2. data flips ------------------------------------------------------
if 'assettypes' in tables:
bind.exec_driver_sql(
"UPDATE assettypes SET assettype='machine', pluginname='machines', "
"tablename='machines' WHERE assettype='equipment'"
)
if 'auditlogs' in tables:
bind.exec_driver_sql(
"UPDATE auditlogs SET entitytype='Machine' "
"WHERE entitytype='Equipment'"
)
if 'settings' in tables:
_rename_setting_keys(bind, old_marker='_equipment_',
new_marker='_machine_')
if 'permissions' in tables:
_rename_permissions(bind, old_prefix='equipment.',
new_prefix='machines.',
old_category='equipment', new_category='machines')
# -- 3. plugin version table --------------------------------------------
if ('alembic_version_equipment' in tables
and 'alembic_version_machines' not in tables):
bind.exec_driver_sql(
"RENAME TABLE alembic_version_equipment TO alembic_version_machines"
)
def _rename_setting_keys(bind, old_marker, new_marker):
"""Rename settings keys embedding the assettype value, collision-safe.
Covers identifier_<name>_equipment_enabled and search_equipment_enabled.
If a row with the new key already exists (fresh seed ran on new code),
the old row is dropped instead of renamed.
"""
# broad fetch; the marker replace below filters non-matches
rows = bind.exec_driver_sql(
"SELECT settingid, `key` FROM settings"
).fetchall()
for settingid, key in rows:
newkey = key.replace(old_marker, new_marker)
if newkey == key:
continue
exists = bind.exec_driver_sql(
"SELECT 1 FROM settings WHERE `key` = %s", (newkey,)
).fetchone()
if exists:
bind.exec_driver_sql(
"DELETE FROM settings WHERE settingid = %s", (settingid,))
else:
bind.exec_driver_sql(
"UPDATE settings SET `key` = %s WHERE settingid = %s",
(newkey, settingid))
def _rename_permissions(bind, old_prefix, new_prefix, old_category,
new_category):
"""Rename permission rows in place so role links survive, collision-safe."""
rows = bind.exec_driver_sql(
"SELECT permissionid, name FROM permissions"
).fetchall()
for permissionid, name in rows:
if not name.startswith(old_prefix):
continue
newname = new_prefix + name[len(old_prefix):]
exists = bind.exec_driver_sql(
"SELECT 1 FROM permissions WHERE name = %s", (newname,)
).fetchone()
if exists:
bind.exec_driver_sql(
"DELETE FROM permissions WHERE permissionid = %s",
(permissionid,))
else:
bind.exec_driver_sql(
"UPDATE permissions SET name = %s, category = %s "
"WHERE permissionid = %s",
(newname, new_category, permissionid))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
tables = set(insp.get_table_names())
if ('alembic_version_machines' in tables
and 'alembic_version_equipment' not in tables):
bind.exec_driver_sql(
"RENAME TABLE alembic_version_machines TO alembic_version_equipment"
)
if 'settings' in tables:
_rename_setting_keys(bind, old_marker='_machine_',
new_marker='_equipment_')
if 'permissions' in tables:
_rename_permissions(bind, old_prefix='machines.',
new_prefix='equipment.',
old_category='machines', new_category='equipment')
if 'auditlogs' in tables:
bind.exec_driver_sql(
"UPDATE auditlogs SET entitytype='Equipment' "
"WHERE entitytype='Machine'"
)
if 'assettypes' in tables:
bind.exec_driver_sql(
"UPDATE assettypes SET assettype='equipment', "
"pluginname='equipment', tablename='equipment' "
"WHERE assettype='machine'"
)
if 'modeltypes' in tables and 'machinetypes' not in tables:
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0")
for name in _fk_names(insp, 'models', 'modeltypes'):
bind.exec_driver_sql(f"ALTER TABLE models DROP FOREIGN KEY {name}")
model_indexes = {ix['name'] for ix in insp.get_indexes('models')}
if 'modeltypeid' in model_indexes:
bind.exec_driver_sql("ALTER TABLE models DROP KEY modeltypeid")
bind.exec_driver_sql("RENAME TABLE modeltypes TO machinetypes")
bind.exec_driver_sql(
"ALTER TABLE machinetypes "
"CHANGE modeltypeid machinetypeid INT NOT NULL AUTO_INCREMENT, "
"CHANGE modeltype machinetype VARCHAR(100) NOT NULL, "
"DROP KEY modeltype, "
"ADD UNIQUE KEY machinetype (machinetype)"
)
bind.exec_driver_sql(
"ALTER TABLE models "
"CHANGE modeltypeid machinetypeid INT NULL, "
"ADD KEY machinetypeid (machinetypeid), "
"ADD CONSTRAINT models_ibfk_1 "
"FOREIGN KEY (machinetypeid) REFERENCES machinetypes (machinetypeid)"
)
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1")

View File

@@ -62,7 +62,7 @@ class Computer(BaseModel):
nullable=True
)
# Hardware make/model (PCs carry vendor + model like equipment)
# Hardware make/model (PCs carry vendor + model like machines)
vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),

View File

@@ -1,5 +0,0 @@
"""Equipment plugin for ShopDB."""
from .plugin import EquipmentPlugin
__all__ = ['EquipmentPlugin']

View File

@@ -1,5 +0,0 @@
"""Equipment plugin API."""
from .routes import equipment_bp
__all__ = ['equipment_bp']

View File

@@ -1,8 +0,0 @@
"""Equipment plugin models."""
from .equipment import Equipment, EquipmentType
__all__ = [
'Equipment',
'EquipmentType',
]

View File

@@ -0,0 +1,5 @@
"""Machines plugin for ShopDB."""
from .plugin import MachinesPlugin
__all__ = ['MachinesPlugin']

View File

@@ -0,0 +1,5 @@
"""Machines plugin API."""
from .routes import machines_bp
__all__ = ['machines_bp']

View File

@@ -1,36 +1,36 @@
"""Equipment plugin API endpoints."""
"""Machines plugin API endpoints."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Equipment, EquipmentType
from ..models import Machine, MachineType
from shopdb.api import require_permission, require_role
equipment_bp = Blueprint('equipment', __name__)
machines_bp = Blueprint('machines', __name__)
# =============================================================================
# Equipment Types
# Machine Types
# =============================================================================
@equipment_bp.route('/types', methods=['GET'])
@machines_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
def list_equipment_types():
"""List all equipment types."""
def list_machine_types():
"""List all machine types."""
page, per_page = get_pagination_params(request)
query = EquipmentType.query
query = MachineType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(EquipmentType.isactive == True)
query = query.filter(MachineType.isactive == True)
if search := request.args.get('search'):
query = query.filter(EquipmentType.equipmenttype.ilike(f'%{search}%'))
query = query.filter(MachineType.machinetype.ilike(f'%{search}%'))
query = query.order_by(EquipmentType.equipmenttype)
query = query.order_by(MachineType.machinetype)
items, total = paginate_query(query, page, per_page)
data = [t.to_dict() for t in items]
@@ -38,33 +38,33 @@ def list_equipment_types():
return paginated_response(data, page, per_page, total)
@equipment_bp.route('/types/<int:type_id>', methods=['GET'])
@machines_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
def get_equipment_type(type_id: int):
"""Get a single equipment type."""
t = db.session.get(EquipmentType, type_id)
def get_machine_type(type_id: int):
"""Get a single machine type."""
t = db.session.get(MachineType, type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment type with ID {type_id} not found',
f'Machine type with ID {type_id} not found',
http_code=404
)
return success_response(t.to_dict())
@equipment_bp.route('/types', methods=['POST'])
@machines_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('equipment.create')
def create_equipment_type():
"""Create a new equipment type."""
@require_permission('machines.create')
def create_machine_type():
"""Create a new machine type."""
data = request.get_json()
if not data or not data.get('equipmenttype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'equipmenttype is required')
if not data or not data.get('machinetype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'machinetype is required')
existing = EquipmentType.query.filter_by(equipmenttype=data['equipmenttype']).first()
existing = MachineType.query.filter_by(machinetype=data['machinetype']).first()
if existing:
if not existing.isactive:
existing.isactive = True
@@ -75,12 +75,12 @@ def create_equipment_type():
return success_response(existing.to_dict(), message='Reactivated existing type')
return error_response(
ErrorCodes.CONFLICT,
f"Equipment type '{data['equipmenttype']}' already exists",
f"Machine type '{data['machinetype']}' already exists",
http_code=409
)
t = EquipmentType(
equipmenttype=data['equipmenttype'],
t = MachineType(
machinetype=data['machinetype'],
description=data.get('description'),
icon=data.get('icon'), color=data.get('color')
)
@@ -88,20 +88,20 @@ def create_equipment_type():
db.session.add(t)
db.session.commit()
return success_response(t.to_dict(), message='Equipment type created', http_code=201)
return success_response(t.to_dict(), message='Machine type created', http_code=201)
@equipment_bp.route('/types/<int:type_id>', methods=['PUT'])
@machines_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('equipment.edit')
def update_equipment_type(type_id: int):
"""Update an equipment type."""
t = db.session.get(EquipmentType, type_id)
@require_permission('machines.edit')
def update_machine_type(type_id: int):
"""Update a machine type."""
t = db.session.get(MachineType, type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment type with ID {type_id} not found',
f'Machine type with ID {type_id} not found',
http_code=404
)
@@ -109,62 +109,62 @@ def update_equipment_type(type_id: int):
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if 'equipmenttype' in data and data['equipmenttype'] != t.equipmenttype:
if EquipmentType.query.filter_by(equipmenttype=data['equipmenttype']).first():
if 'machinetype' in data and data['machinetype'] != t.machinetype:
if MachineType.query.filter_by(machinetype=data['machinetype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Equipment type '{data['equipmenttype']}' already exists",
f"Machine type '{data['machinetype']}' already exists",
http_code=409
)
for key in ['equipmenttype', 'description', 'icon', 'color', 'isactive']:
for key in ['machinetype', 'description', 'icon', 'color', 'isactive']:
if key in data:
setattr(t, key, data[key])
db.session.commit()
return success_response(t.to_dict(), message='Equipment type updated')
return success_response(t.to_dict(), message='Machine type updated')
@equipment_bp.route('/types/<int:type_id>', methods=['DELETE'])
@machines_bp.route('/types/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('equipment.delete')
def delete_equipment_type(type_id: int):
"""Delete an equipment type. Refused if any asset still uses it."""
t = db.session.get(EquipmentType, type_id)
@require_permission('machines.delete')
def delete_machine_type(type_id: int):
"""Delete a machine type. Refused if any asset still uses it."""
t = db.session.get(MachineType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Equipment type not found', http_code=404)
inuse = Equipment.query.filter_by(equipmenttypeid=type_id).count()
return error_response(ErrorCodes.NOT_FOUND, 'Machine type not found', http_code=404)
inuse = Machine.query.filter_by(machinetypeid=type_id).count()
if inuse:
return error_response(ErrorCodes.CONFLICT,
f"Cannot delete: {inuse} asset(s) still use this type", http_code=409)
db.session.delete(t)
db.session.commit()
return success_response(message='Equipment type deleted')
return success_response(message='Machine type deleted')
# =============================================================================
# Equipment CRUD
# Machine CRUD
# =============================================================================
@equipment_bp.route('', methods=['GET'])
@machines_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_equipment():
def list_machines():
"""
List all equipment with filtering and pagination.
List all machines with filtering and pagination.
Query parameters:
- page, per_page: Pagination
- active: Filter by active status
- search: Search by asset number or name
- type_id: Filter by equipment type ID
- type_id: Filter by machine type ID
- vendor_id: Filter by vendor ID
- location_id: Filter by location ID
- businessunit_id: Filter by business unit ID
"""
page, per_page = get_pagination_params(request)
# Join Equipment with Asset
query = db.session.query(Equipment).join(Asset)
# Join Machine with Asset
query = db.session.query(Machine).join(Asset)
# Active filter
if request.args.get('active', 'true').lower() != 'false':
@@ -180,13 +180,13 @@ def list_equipment():
)
)
# Equipment type filter
# Machine type filter
if type_id := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Equipment.equipmenttypeid == int(type_id))
query = query.filter(Machine.machinetypeid == int(type_id))
# Vendor filter
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
query = query.filter(Equipment.vendorid == int(vendor_id))
query = query.filter(Machine.vendorid == int(vendor_id))
# Location filter
if location_id := request.args.get('locationid', request.args.get('location_id')):
@@ -211,67 +211,67 @@ def list_equipment():
items, total = paginate_query(query, page, per_page)
# Build response with both asset and equipment data
# Build response with both asset and machine data
data = []
for equip in items:
item = equip.asset.to_dict() if equip.asset else {}
item['equipment'] = equip.to_dict()
for mach in items:
item = mach.asset.to_dict() if mach.asset else {}
item['machine'] = mach.to_dict()
data.append(item)
return paginated_response(data, page, per_page, total)
@equipment_bp.route('/<int:equipment_id>', methods=['GET'])
@machines_bp.route('/<int:machine_id>', methods=['GET'])
@jwt_required(optional=True)
def get_equipment(equipment_id: int):
"""Get a single equipment item with full details."""
equip = db.session.get(Equipment, equipment_id)
def get_machine(machine_id: int):
"""Get a single machine item with full details."""
mach = db.session.get(Machine, machine_id)
if not equip:
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment with ID {equipment_id} not found',
f'Machine with ID {machine_id} not found',
http_code=404
)
result = equip.asset.to_dict() if equip.asset else {}
result['equipment'] = equip.to_dict()
result = mach.asset.to_dict() if mach.asset else {}
result['machine'] = mach.to_dict()
return success_response(result)
@equipment_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@machines_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
def get_equipment_by_asset(asset_id: int):
"""Get equipment data by asset ID."""
equip = Equipment.query.filter_by(assetid=asset_id).first()
def get_machine_by_asset(asset_id: int):
"""Get machine data by asset ID."""
mach = Machine.query.filter_by(assetid=asset_id).first()
if not equip:
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment for asset {asset_id} not found',
f'Machine for asset {asset_id} not found',
http_code=404
)
result = equip.asset.to_dict() if equip.asset else {}
result['equipment'] = equip.to_dict()
result = mach.asset.to_dict() if mach.asset else {}
result['machine'] = mach.to_dict()
return success_response(result)
@equipment_bp.route('', methods=['POST'])
@machines_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('equipment.create')
def create_equipment():
@require_permission('machines.create')
def create_machine():
"""
Create new equipment (creates both Asset and Equipment records).
Create new machine (creates both Asset and Machine records).
Required fields:
- assetnumber: Business identifier
Optional fields:
- name, serialnumber, statusid, locationid, businessunitid
- equipmenttypeid, vendorid, modelnumberid
- machinetypeid, vendorid, modelnumberid
- requiresmanualconfig, islocationonly
- mapx, mapy, notes
"""
@@ -291,12 +291,12 @@ def create_equipment():
http_code=409
)
# Get equipment asset type
equipment_type = AssetType.query.filter_by(assettype='equipment').first()
if not equipment_type:
# Get machine asset type
machine_type = AssetType.query.filter_by(assettype='machine').first()
if not machine_type:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Equipment asset type not found. Plugin may not be properly installed.',
'Machine asset type not found. Plugin may not be properly installed.',
http_code=500
)
@@ -307,7 +307,7 @@ def create_equipment():
gaugelabreference=data.get('gaugelabreference'),
maintenancereference=data.get('maintenancereference'),
serialnumber=data.get('serialnumber'),
assettypeid=equipment_type.assettypeid,
assettypeid=machine_type.assettypeid,
statusid=data.get('statusid', 1),
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
@@ -319,10 +319,10 @@ def create_equipment():
db.session.add(asset)
db.session.flush() # Get the assetid
# Create the equipment extension
equip = Equipment(
# Create the machine extension
mach = Machine(
assetid=asset.assetid,
equipmenttypeid=data.get('equipmenttypeid'),
machinetypeid=data.get('machinetypeid'),
vendorid=data.get('vendorid'),
modelnumberid=data.get('modelnumberid'),
requiresmanualconfig=data.get('requiresmanualconfig', False),
@@ -334,32 +334,32 @@ def create_equipment():
controllermodelid=data.get('controllermodelid')
)
db.session.add(equip)
db.session.add(mach)
db.session.flush()
# Audit log
AuditLog.log('created', 'Equipment', entityid=equip.equipmentid,
AuditLog.log('created', 'Machine', entityid=mach.machineid,
entityname=data['assetnumber'])
db.session.commit()
result = asset.to_dict()
result['equipment'] = equip.to_dict()
result['machine'] = mach.to_dict()
return success_response(result, message='Equipment created', http_code=201)
return success_response(result, message='Machine created', http_code=201)
@equipment_bp.route('/<int:equipment_id>', methods=['PUT'])
@machines_bp.route('/<int:machine_id>', methods=['PUT'])
@jwt_required()
@require_permission('equipment.edit')
def update_equipment(equipment_id: int):
"""Update equipment (both Asset and Equipment records)."""
equip = db.session.get(Equipment, equipment_id)
@require_permission('machines.edit')
def update_machine(machine_id: int):
"""Update machine (both Asset and Machine records)."""
mach = db.session.get(Machine, machine_id)
if not equip:
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment with ID {equipment_id} not found',
f'Machine with ID {machine_id} not found',
http_code=404
)
@@ -367,7 +367,7 @@ def update_equipment(equipment_id: int):
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
asset = equip.asset
asset = mach.asset
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
@@ -394,87 +394,87 @@ def update_equipment(equipment_id: int):
changes[key] = {'old': old_val, 'new': new_val}
setattr(asset, key, data[key])
# Update equipment fields
equipment_fields = ['equipmenttypeid', 'vendorid', 'modelnumberid',
# Update machine fields
machine_fields = ['machinetypeid', 'vendorid', 'modelnumberid',
'requiresmanualconfig', 'islocationonly',
'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays',
'controllervendorid', 'controllermodelid']
for key in equipment_fields:
for key in machine_fields:
if key in data:
old_val = getattr(equip, key)
old_val = getattr(mach, key)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(equip, key, data[key])
setattr(mach, key, data[key])
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'Equipment', entityid=equip.equipmentid,
AuditLog.log('updated', 'Machine', entityid=mach.machineid,
entityname=asset.assetnumber, changes=changes)
db.session.commit()
result = asset.to_dict()
result['equipment'] = equip.to_dict()
result['machine'] = mach.to_dict()
return success_response(result, message='Equipment updated')
return success_response(result, message='Machine updated')
@equipment_bp.route('/<int:equipment_id>', methods=['DELETE'])
@machines_bp.route('/<int:machine_id>', methods=['DELETE'])
@jwt_required()
@require_permission('equipment.delete')
def delete_equipment(equipment_id: int):
"""Delete (soft delete) equipment."""
equip = db.session.get(Equipment, equipment_id)
@require_permission('machines.delete')
def delete_machine(machine_id: int):
"""Delete (soft delete) machine."""
mach = db.session.get(Machine, machine_id)
if not equip:
if not mach:
return error_response(
ErrorCodes.NOT_FOUND,
f'Equipment with ID {equipment_id} not found',
f'Machine with ID {machine_id} not found',
http_code=404
)
# Soft delete the asset (equipment extension will stay linked)
equip.asset.isactive = False
# Soft delete the asset (machine extension will stay linked)
mach.asset.isactive = False
# Audit log
AuditLog.log('deleted', 'Equipment', entityid=equip.equipmentid,
entityname=equip.asset.assetnumber)
AuditLog.log('deleted', 'Machine', entityid=mach.machineid,
entityname=mach.asset.assetnumber)
db.session.commit()
return success_response(message='Equipment deleted')
return success_response(message='Machine deleted')
# =============================================================================
# Dashboard
# =============================================================================
@equipment_bp.route('/dashboard/summary', methods=['GET'])
@machines_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required(optional=True)
def dashboard_summary():
"""Get equipment dashboard summary data."""
# Total active equipment count
total = db.session.query(Equipment).join(Asset).filter(
"""Get machine dashboard summary data."""
# Total active machine count
total = db.session.query(Machine).join(Asset).filter(
Asset.isactive == True
).count()
# Count by equipment type
# Count by machine type
by_type = db.session.query(
EquipmentType.equipmenttype,
db.func.count(Equipment.equipmentid)
).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
).join(Asset, Asset.assetid == Equipment.assetid
MachineType.machinetype,
db.func.count(Machine.machineid)
).join(Machine, Machine.machinetypeid == MachineType.machinetypeid
).join(Asset, Asset.assetid == Machine.assetid
).filter(Asset.isactive == True
).group_by(EquipmentType.equipmenttype
).group_by(MachineType.machinetype
).all()
# Count by status
from shopdb.api import AssetStatus
by_status = db.session.query(
AssetStatus.status,
db.func.count(Equipment.equipmentid)
).join(Asset, Asset.assetid == Equipment.assetid
db.func.count(Machine.machineid)
).join(Asset, Asset.assetid == Machine.assetid
).join(AssetStatus, AssetStatus.statusid == Asset.statusid
).filter(Asset.isactive == True
).group_by(AssetStatus.status

View File

@@ -1,15 +1,15 @@
{
"name": "equipment",
"name": "machines",
"version": "1.0.0",
"description": "Equipment management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing equipment",
"description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/equipment",
"api_prefix": "/api/machines",
"provides": {
"asset_type": "equipment",
"asset_type": "machine",
"features": [
"equipment_tracking",
"machine_tracking",
"maintenance_scheduling",
"vendor_management",
"model_catalog"

View File

@@ -1,13 +1,13 @@
"""Alembic environment for the equipment plugin migration chain.
"""Alembic environment for the machines plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_equipment. See ADR-008 for the
per-plugin version table alembic_version_machines. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'equipment'
os.environ['PLUGIN_NAME'] = 'machines'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402

View File

@@ -1,11 +1,15 @@
"""equipment plugin anchor (ownership cutover).
"""machines plugin anchor (ownership cutover; authored as equipment).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_equipment. From this
anchor forward, new equipment schema changes land as 000N revisions in this
`flask plugin upgrade-all` can stamp into alembic_version_machines. From this
anchor forward, new machines schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
The revision id keeps its original 'equipment0001anchor' string: ids are
arbitrary and existing installs already carry it in their version table
(which the core chain renames to alembic_version_machines).
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401

View File

@@ -0,0 +1,168 @@
"""Rename equipment tables to machines
Plugin half of the approved equipment -> machines rename (the core half is
migrations/versions/7d17_machines_rename.py):
equipment -> machines (equipmentid -> machineid,
equipmenttypeid -> machinetypeid)
equipmenttypes -> machinetypes (equipmenttypeid -> machinetypeid,
equipmenttype -> machinetype)
Runs on BOTH upgraded installs and fresh installs: the core baseline creates
these tables under their old names (frozen DDL), so this revision always does
the physical rename. Idempotent: skips when the tables already carry the new
names (e.g. test DBs built by db.create_all() from current models). Refuses
to run while the core machinetypes table still exists - `flask db upgrade`
(which renames it to modeltypes) must land first.
Revision ID: machines0002rename
Revises: equipment0001anchor
"""
from alembic import op
import sqlalchemy as sa
revision = 'machines0002rename'
down_revision = 'equipment0001anchor'
branch_labels = None
depends_on = None
def _fk_names(insp, table, referred_table):
# find FK constraint names on table pointing at referred_table
return [fk['name'] for fk in insp.get_foreign_keys(table)
if fk.get('referred_table') == referred_table and fk.get('name')]
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
tables = set(insp.get_table_names())
if 'equipment' not in tables and 'equipmenttypes' not in tables:
# already renamed, or fresh schema built straight from current models
return
if 'machinetypes' in tables:
# core chain still owns the machinetypes name (not yet renamed to
# modeltypes) - renaming equipmenttypes onto it would collide
raise RuntimeError(
"Core migration 7d17_machines_rename has not run: the legacy "
"machinetypes table still exists. Run `flask db upgrade` before "
"`flask plugin upgrade-all`."
)
if bind.dialect.name == 'mysql':
_upgrade_mysql(bind, insp)
else:
_upgrade_generic(insp)
def _upgrade_mysql(bind, insp):
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0")
# snapshot index names before the renames invalidate the table name
machine_indexes = {ix['name'] for ix in insp.get_indexes('equipment')}
# drop the equipment -> equipmenttypes FK before renaming the parent cols
for name in _fk_names(insp, 'equipment', 'equipmenttypes'):
bind.exec_driver_sql(f"ALTER TABLE equipment DROP FOREIGN KEY {name}")
bind.exec_driver_sql("RENAME TABLE equipmenttypes TO machinetypes")
bind.exec_driver_sql("RENAME TABLE equipment TO machines")
bind.exec_driver_sql(
"ALTER TABLE machinetypes "
"CHANGE equipmenttypeid machinetypeid INT NOT NULL AUTO_INCREMENT, "
"CHANGE equipmenttype machinetype VARCHAR(100) NOT NULL, "
"DROP KEY equipmenttype, "
"ADD UNIQUE KEY machinetype (machinetype)"
)
parts = [
"CHANGE equipmentid machineid INT NOT NULL AUTO_INCREMENT",
"CHANGE equipmenttypeid machinetypeid INT NULL",
]
# rename the model-declared index names to match the Machine model
if 'idx_equipment_type' in machine_indexes:
parts += ["DROP KEY idx_equipment_type",
"ADD KEY idx_machine_type (machinetypeid)"]
if 'idx_equipment_vendor' in machine_indexes:
parts += ["DROP KEY idx_equipment_vendor",
"ADD KEY idx_machine_vendor (vendorid)"]
if 'ix_equipment_assetid' in machine_indexes:
parts += ["DROP KEY ix_equipment_assetid",
"ADD UNIQUE KEY ix_machines_assetid (assetid)"]
parts += [
"ADD CONSTRAINT fk_machines_machinetypeid "
"FOREIGN KEY (machinetypeid) REFERENCES machinetypes (machinetypeid)"
]
bind.exec_driver_sql("ALTER TABLE machines " + ", ".join(parts))
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1")
def _upgrade_generic(insp):
# SQLite (and anything else): batch rename via ALTER ... RENAME
op.rename_table('equipmenttypes', 'machinetypes')
op.rename_table('equipment', 'machines')
with op.batch_alter_table('machinetypes') as batch_op:
batch_op.alter_column('equipmenttypeid', new_column_name='machinetypeid',
existing_type=sa.Integer(), existing_nullable=False)
batch_op.alter_column('equipmenttype', new_column_name='machinetype',
existing_type=sa.String(100),
existing_nullable=False)
with op.batch_alter_table('machines') as batch_op:
batch_op.alter_column('equipmentid', new_column_name='machineid',
existing_type=sa.Integer(), existing_nullable=False)
batch_op.alter_column('equipmenttypeid', new_column_name='machinetypeid',
existing_type=sa.Integer(), existing_nullable=True)
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
tables = set(insp.get_table_names())
if 'machines' not in tables and 'machinetypes' not in tables:
return
if bind.dialect.name != 'mysql':
raise NotImplementedError(
"downgrade implemented for MySQL only (dev/prod dialect)")
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0")
machine_indexes = {ix['name'] for ix in insp.get_indexes('machines')}
for name in _fk_names(insp, 'machines', 'machinetypes'):
bind.exec_driver_sql(f"ALTER TABLE machines DROP FOREIGN KEY {name}")
bind.exec_driver_sql("RENAME TABLE machinetypes TO equipmenttypes")
bind.exec_driver_sql("RENAME TABLE machines TO equipment")
bind.exec_driver_sql(
"ALTER TABLE equipmenttypes "
"CHANGE machinetypeid equipmenttypeid INT NOT NULL AUTO_INCREMENT, "
"CHANGE machinetype equipmenttype VARCHAR(100) NOT NULL, "
"DROP KEY machinetype, "
"ADD UNIQUE KEY equipmenttype (equipmenttype)"
)
parts = [
"CHANGE machineid equipmentid INT NOT NULL AUTO_INCREMENT",
"CHANGE machinetypeid equipmenttypeid INT NULL",
]
if 'idx_machine_type' in machine_indexes:
parts += ["DROP KEY idx_machine_type",
"ADD KEY idx_equipment_type (equipmenttypeid)"]
if 'idx_machine_vendor' in machine_indexes:
parts += ["DROP KEY idx_machine_vendor",
"ADD KEY idx_equipment_vendor (vendorid)"]
if 'ix_machines_assetid' in machine_indexes:
parts += ["DROP KEY ix_machines_assetid",
"ADD UNIQUE KEY ix_equipment_assetid (assetid)"]
parts += [
"ADD CONSTRAINT equipment_ibfk_2 "
"FOREIGN KEY (equipmenttypeid) REFERENCES equipmenttypes (equipmenttypeid)"
]
bind.exec_driver_sql("ALTER TABLE equipment " + ", ".join(parts))
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1")

View File

@@ -0,0 +1,8 @@
"""Machines plugin models."""
from .machine import Machine, MachineType
__all__ = [
'Machine',
'MachineType',
]

View File

@@ -1,36 +1,36 @@
"""Equipment plugin models."""
"""Machines plugin models."""
from shopdb.api import db, BaseModel
class EquipmentType(BaseModel):
class MachineType(BaseModel):
"""
Equipment type classification.
Machine type classification.
Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc.
"""
__tablename__ = 'equipmenttypes'
__tablename__ = 'machinetypes'
equipmenttypeid = db.Column(db.Integer, primary_key=True)
equipmenttype = db.Column(db.String(100), unique=True, nullable=False)
machinetypeid = db.Column(db.Integer, primary_key=True)
machinetype = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
def __repr__(self):
return f"<EquipmentType {self.equipmenttype}>"
return f"<MachineType {self.machinetype}>"
class Equipment(BaseModel):
class Machine(BaseModel):
"""
Equipment-specific extension data.
Machine-specific extension data.
Links to core Asset table via assetid.
Stores equipment-specific fields like type, model, vendor, etc.
Stores machine-specific fields like type, model, vendor, etc.
"""
__tablename__ = 'equipment'
__tablename__ = 'machines'
equipmentid = db.Column(db.Integer, primary_key=True)
machineid = db.Column(db.Integer, primary_key=True)
# Link to core asset
assetid = db.Column(
@@ -41,10 +41,10 @@ class Equipment(BaseModel):
index=True
)
# Equipment classification
equipmenttypeid = db.Column(
# Machine classification
machinetypeid = db.Column(
db.Integer,
db.ForeignKey('equipmenttypes.equipmenttypeid'),
db.ForeignKey('machinetypes.machinetypeid'),
nullable=True
)
@@ -60,7 +60,7 @@ class Equipment(BaseModel):
nullable=True
)
# Equipment-specific fields
# Machine-specific fields
requiresmanualconfig = db.Column(
db.Boolean,
default=False,
@@ -69,7 +69,7 @@ class Equipment(BaseModel):
islocationonly = db.Column(
db.Boolean,
default=False,
comment='Virtual location marker (not actual equipment)'
comment='Virtual location marker (not actual machine)'
)
# Maintenance tracking
@@ -94,29 +94,29 @@ class Equipment(BaseModel):
# Relationships
asset = db.relationship(
'Asset',
backref=db.backref('equipment', uselist=False, lazy='joined')
backref=db.backref('machine', uselist=False, lazy='joined')
)
equipmenttype = db.relationship('EquipmentType', backref='equipment')
vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items')
model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items')
controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers')
controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models')
machinetype = db.relationship('MachineType', backref='machines')
vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='machine_items')
model = db.relationship('Model', foreign_keys=[modelnumberid], backref='machine_items')
controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='machine_controllers')
controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='machine_controller_models')
__table_args__ = (
db.Index('idx_equipment_type', 'equipmenttypeid'),
db.Index('idx_equipment_vendor', 'vendorid'),
db.Index('idx_machine_type', 'machinetypeid'),
db.Index('idx_machine_vendor', 'vendorid'),
)
def __repr__(self):
return f"<Equipment {self.assetid}>"
return f"<Machine {self.assetid}>"
def to_dict(self):
"""Convert to dictionary with related names."""
result = super().to_dict()
# Add related object names
if self.equipmenttype:
result['equipmenttypename'] = self.equipmenttype.equipmenttype
if self.machinetype:
result['machinetypename'] = self.machinetype.machinetype
if self.vendor:
result['vendorname'] = self.vendor.vendor
if self.model:

View File

@@ -1,4 +1,4 @@
"""Equipment plugin main class."""
"""Machines plugin main class."""
import json
import logging
@@ -11,18 +11,18 @@ import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db, AssetType, AssetStatus
from .models import Equipment, EquipmentType
from .api import equipment_bp
from .models import Machine, MachineType
from .api import machines_bp
logger = logging.getLogger(__name__)
class EquipmentPlugin(BasePlugin):
class MachinesPlugin(BasePlugin):
"""
Equipment plugin - manages manufacturing equipment assets.
Machines plugin - manages manufacturing machine assets.
Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc.
Uses the new Asset architecture with Equipment extension table.
Machines include CNCs, CMMs, lathes, grinders, EDMs, part markers, etc.
Uses the new Asset architecture with Machine extension table.
"""
def __init__(self):
@@ -40,51 +40,51 @@ class EquipmentPlugin(BasePlugin):
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'equipment'),
name=self._manifest.get('name', 'machines'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get(
'description',
'Equipment management for manufacturing assets'
'Machine management for manufacturing assets'
),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/equipment'),
api_prefix=self._manifest.get('api_prefix', '/api/machines'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return equipment_bp
return machines_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [Equipment, EquipmentType]
return [Machine, MachineType]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"Equipment plugin initialized (v{self.meta.version})")
logger.info(f"Machines plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed."""
with app.app_context():
self._ensure_asset_type()
self._ensure_asset_statuses()
self._ensure_equipment_types()
logger.info("Equipment plugin installed")
self._ensure_machine_types()
logger.info("Machines plugin installed")
def _ensure_asset_type(self) -> None:
"""Ensure equipment asset type exists."""
existing = AssetType.query.filter_by(assettype='equipment').first()
"""Ensure machine asset type exists."""
existing = AssetType.query.filter_by(assettype='machine').first()
if not existing:
at = AssetType(
assettype='equipment',
pluginname='equipment',
tablename='equipment',
description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)',
assettype='machine',
pluginname='machines',
tablename='machines',
description='Manufacturing machines (CNCs, CMMs, lathes, etc.)',
icon='cog'
)
db.session.add(at)
logger.debug("Created asset type: equipment")
logger.debug("Created asset type: machine")
db.session.commit()
def _ensure_asset_statuses(self) -> None:
@@ -110,82 +110,82 @@ class EquipmentPlugin(BasePlugin):
db.session.commit()
def _ensure_equipment_types(self) -> None:
"""Ensure basic equipment types exist."""
equipment_types = [
def _ensure_machine_types(self) -> None:
"""Ensure basic machine types exist."""
machine_types = [
('CNC', 'Computer Numerical Control machine', 'cnc'),
('CMM', 'Coordinate Measuring Machine', 'cmm'),
('Lathe', 'Lathe machine', 'lathe'),
('Grinder', 'Grinding machine', 'grinder'),
('EDM', 'Electrical Discharge Machine', 'edm'),
('Part Marker', 'Part marking/engraving equipment', 'marker'),
('Part Marker', 'Part marking/engraving machine', 'marker'),
('Mill', 'Milling machine', 'mill'),
('Press', 'Press machine', 'press'),
('Robot', 'Industrial robot', 'robot'),
('Other', 'Other equipment type', 'cog'),
('Other', 'Other machine type', 'cog'),
]
for name, description, icon in equipment_types:
existing = EquipmentType.query.filter_by(equipmenttype=name).first()
for name, description, icon in machine_types:
existing = MachineType.query.filter_by(machinetype=name).first()
if not existing:
et = EquipmentType(
equipmenttype=name,
mt = MachineType(
machinetype=name,
description=description,
icon=icon
)
db.session.add(et)
logger.debug(f"Created equipment type: {name}")
db.session.add(mt)
logger.debug(f"Created machine type: {name}")
db.session.commit()
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled."""
logger.info("Equipment plugin uninstalled")
logger.info("Machines plugin uninstalled")
def get_cli_commands(self) -> List:
"""Return CLI commands for this plugin."""
@click.group('equipment')
def equipmentcli():
"""Equipment plugin commands."""
@click.group('machines')
def machinescli():
"""Machines plugin commands."""
pass
@equipmentcli.command('list-types')
@machinescli.command('list-types')
def list_types():
"""List all equipment types."""
"""List all machine types."""
from flask import current_app
with current_app.app_context():
types = EquipmentType.query.filter_by(isactive=True).all()
types = MachineType.query.filter_by(isactive=True).all()
if not types:
click.echo('No equipment types found.')
click.echo('No machine types found.')
return
click.echo('Equipment Types:')
click.echo('Machine Types:')
for t in types:
click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}")
click.echo(f" [{t.machinetypeid}] {t.machinetype}")
@equipmentcli.command('stats')
@machinescli.command('stats')
def stats():
"""Show equipment statistics."""
"""Show machine statistics."""
from flask import current_app
from shopdb.api import Asset
with current_app.app_context():
total = db.session.query(Equipment).join(Asset).filter(
total = db.session.query(Machine).join(Asset).filter(
Asset.isactive == True
).count()
click.echo(f"Total active equipment: {total}")
click.echo(f"Total active machines: {total}")
# By type
by_type = db.session.query(
EquipmentType.equipmenttype,
db.func.count(Equipment.equipmentid)
).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
).join(Asset, Asset.assetid == Equipment.assetid
MachineType.machinetype,
db.func.count(Machine.machineid)
).join(Machine, Machine.machinetypeid == MachineType.machinetypeid
).join(Asset, Asset.assetid == Machine.assetid
).filter(Asset.isactive == True
).group_by(EquipmentType.equipmenttype
).group_by(MachineType.machinetype
).all()
if by_type:
@@ -193,15 +193,15 @@ class EquipmentPlugin(BasePlugin):
for t, c in by_type:
click.echo(f" {t}: {c}")
return [equipmentcli]
return [machinescli]
def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions."""
return [
{
'name': 'Machine Status',
'component': 'EquipmentStatusWidget',
'endpoint': '/api/equipment/dashboard/summary',
'component': 'MachineStatusWidget',
'endpoint': '/api/machines/dashboard/summary',
'size': 'medium',
'position': 5,
},

View File

@@ -2,7 +2,7 @@
Measuring tools are gage-lab instruments (calipers, micrometers, thread gages,
bore gages, height gages, Genspect heads, ...) that measure parts, as opposed
to equipment that makes parts (see ADR-005). Each tool is a core Asset plus a
to machines that make parts (see ADR-005). Each tool is a core Asset plus a
one-to-one measuringtools extension row.
The lifecycle a measuring tool cares about is CALIBRATION, not maintenance:
@@ -50,7 +50,7 @@ class MeasuringToolType(BaseModel):
"""Measuring-tool classification (Caliper, Micrometer, Thread Gage, ...).
Site-managed lookup with a display color for badges and map markers,
the same shape as the equipment/computer/printer type tables.
the same shape as the machine/computer/printer type tables.
"""
__tablename__ = 'measuringtooltypes'

View File

@@ -317,7 +317,7 @@ def seedsupplies():
targets = _matching_models(family['matchkeys'], vendor.vendorid)
if not targets:
# machinetypeid is a legacy Model column (nullable); printers are
# modeltypeid is a legacy Model column (nullable); printers are
# asset-based now and carry their type via PrinterType, not here.
model = Model(
modelnumber=family['canonical'],

View File

@@ -2,7 +2,7 @@
Asset-general plugin: owns its warranties + warrantyassets tables, an API
surface, and a sidebar entry. Not tied to any one asset type - a warranty can
cover a PC, printer, network device, or equipment.
cover a PC, printer, network device, or machine.
"""
import json

View File

@@ -101,7 +101,7 @@ def create_app(config_name: str = None) -> Flask:
CORE_BLUEPRINT_NAMES = (
'auth',
'assets',
'machinetypes',
'modeltypes',
'plugins',
'vendors',
'models',

View File

@@ -125,31 +125,31 @@ def seed_cli():
@seed_cli.command('reference-data')
@with_appcontext
def seed_reference_data():
"""Seed reference data (machine types, statuses, etc.)."""
"""Seed reference data (model types, statuses, etc.)."""
from shopdb.extensions import db
from shopdb.core.models import MachineType, OperatingSystem, AssetStatus, LocationType
from shopdb.core.models import ModelType, OperatingSystem, AssetStatus, LocationType
from shopdb.core.models.relationship import RelationshipType
# Machine types
machine_types = [
{'machinetype': 'CNC Mill', 'category': 'Equipment', 'description': 'CNC Milling Machine'},
{'machinetype': 'CNC Lathe', 'category': 'Equipment', 'description': 'CNC Lathe'},
{'machinetype': 'CMM', 'category': 'Equipment', 'description': 'Coordinate Measuring Machine'},
{'machinetype': 'EDM', 'category': 'Equipment', 'description': 'Electrical Discharge Machine'},
{'machinetype': 'Grinder', 'category': 'Equipment', 'description': 'Grinding Machine'},
{'machinetype': 'Inspection Station', 'category': 'Equipment', 'description': 'Inspection Station'},
{'machinetype': 'Desktop PC', 'category': 'PC', 'description': 'Desktop Computer'},
{'machinetype': 'Laptop', 'category': 'PC', 'description': 'Laptop Computer'},
{'machinetype': 'Shopfloor PC', 'category': 'PC', 'description': 'Shopfloor Computer'},
{'machinetype': 'Server', 'category': 'Network', 'description': 'Server'},
{'machinetype': 'Switch', 'category': 'Network', 'description': 'Network Switch'},
{'machinetype': 'Access Point', 'category': 'Network', 'description': 'Wireless Access Point'},
# Model types (type the vendor models catalog)
model_types = [
{'modeltype': 'CNC Mill', 'category': 'Equipment', 'description': 'CNC Milling Machine'},
{'modeltype': 'CNC Lathe', 'category': 'Equipment', 'description': 'CNC Lathe'},
{'modeltype': 'CMM', 'category': 'Equipment', 'description': 'Coordinate Measuring Machine'},
{'modeltype': 'EDM', 'category': 'Equipment', 'description': 'Electrical Discharge Machine'},
{'modeltype': 'Grinder', 'category': 'Equipment', 'description': 'Grinding Machine'},
{'modeltype': 'Inspection Station', 'category': 'Equipment', 'description': 'Inspection Station'},
{'modeltype': 'Desktop PC', 'category': 'PC', 'description': 'Desktop Computer'},
{'modeltype': 'Laptop', 'category': 'PC', 'description': 'Laptop Computer'},
{'modeltype': 'Shopfloor PC', 'category': 'PC', 'description': 'Shopfloor Computer'},
{'modeltype': 'Server', 'category': 'Network', 'description': 'Server'},
{'modeltype': 'Switch', 'category': 'Network', 'description': 'Network Switch'},
{'modeltype': 'Access Point', 'category': 'Network', 'description': 'Wireless Access Point'},
]
for mt_data in machine_types:
existing = MachineType.query.filter_by(machinetype=mt_data['machinetype']).first()
for mt_data in model_types:
existing = ModelType.query.filter_by(modeltype=mt_data['modeltype']).first()
if not existing:
mt = MachineType(**mt_data)
mt = ModelType(**mt_data)
db.session.add(mt)
# Asset statuses (canonical set - the asset model is the contract)

View File

@@ -2,7 +2,7 @@
from .auth import auth_bp
from .assets import assets_bp
from .machinetypes import machinetypes_bp
from .modeltypes import modeltypes_bp
from .plugins import plugins_bp
from .vendors import vendors_bp
from .models import models_bp
@@ -24,7 +24,7 @@ from .setup import setup_bp
__all__ = [
'auth_bp',
'assets_bp',
'machinetypes_bp',
'modeltypes_bp',
'plugins_bp',
'vendors_bp',
'models_bp',

View File

@@ -332,7 +332,7 @@ def list_assets():
- per_page: Items per page (default: 20, max: 100)
- active: Filter by active status (default: true)
- search: Search by assetnumber or name
- type: Filter by asset type name (e.g., 'equipment', 'computer')
- type: Filter by asset type name (e.g., 'machine', 'computer')
- type_id: Filter by asset type ID
- status_id: Filter by status ID
- location_id: Filter by location ID
@@ -699,8 +699,8 @@ def get_assets_map():
Returns assets with mapx/mapy coordinates, joined with type-specific data.
Query parameters:
- assettype: Filter by asset type name (equipment, computer, network_device, printer)
- subtype: Filter by subtype ID (machinetype for equipment/computer, networkdevicetype for network, printertype for printer)
- assettype: Filter by asset type name (machine, computer, network_device, printer)
- subtype: Filter by subtype ID (machinetype for machines, computertype for PCs, networkdevicetype for network, printertype for printer)
- businessunitid: Filter by business unit ID
- statusid: Filter by status ID
- locationid: Filter by location ID
@@ -720,26 +720,26 @@ def get_assets_map():
# Eager-load plugin extension tables AND their relationships
try:
from plugins.equipment.models import Equipment
from plugins.machines.models import Machine
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.equipmenttype)
subqueryload(Asset.machine)
.joinedload(Machine.machinetype)
)
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.vendor)
subqueryload(Asset.machine)
.joinedload(Machine.vendor)
)
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.model)
subqueryload(Asset.machine)
.joinedload(Machine.model)
)
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.controllervendor)
subqueryload(Asset.machine)
.joinedload(Machine.controllervendor)
)
eager_options.append(
subqueryload(Asset.equipment)
.joinedload(Equipment.controllermodel)
subqueryload(Asset.machine)
.joinedload(Machine.controllermodel)
)
except (ImportError, AttributeError):
pass
@@ -800,11 +800,11 @@ def get_assets_map():
if subtype_id := request.args.get('subtype'):
subtype_id = int(subtype_id)
asset_type_lower = selected_assettype.lower() if selected_assettype else ''
if asset_type_lower == 'equipment':
if asset_type_lower == 'machine':
try:
from plugins.equipment.models import Equipment
query = query.join(Equipment, Equipment.assetid == Asset.assetid).filter(
Equipment.equipmenttypeid == subtype_id
from plugins.machines.models import Machine
query = query.join(Machine, Machine.assetid == Asset.assetid).filter(
Machine.machinetypeid == subtype_id
)
except ImportError:
pass
@@ -926,11 +926,11 @@ def get_assets_map():
subtypes = {}
try:
from plugins.equipment.models import EquipmentType
equipment_types = EquipmentType.query.filter(EquipmentType.isactive == True).order_by(EquipmentType.equipmenttype).all()
subtypes['Equipment'] = [{'id': et.equipmenttypeid, 'name': et.equipmenttype, 'color': et.color} for et in equipment_types]
from plugins.machines.models import MachineType
machine_types = MachineType.query.filter(MachineType.isactive == True).order_by(MachineType.machinetype).all()
subtypes['Machine'] = [{'id': mt.machinetypeid, 'name': mt.machinetype, 'color': mt.color} for mt in machine_types]
except ImportError:
subtypes['Equipment'] = []
subtypes['Machine'] = []
try:
from plugins.computers.models import ComputerType

View File

@@ -11,7 +11,7 @@ dashboard_bp = Blueprint('dashboard', __name__)
# Map asset type name -> dashboard category label
_TYPE_CATEGORY = {
'equipment': 'Equipment',
'machine': 'Machine',
'computer': 'PC',
'printer': 'Printer',
'network_device': 'Network',
@@ -30,11 +30,11 @@ def _count_by_type(assettype):
@jwt_required(optional=True)
def get_dashboard():
"""Get dashboard summary data (asset-based)."""
equipment_count = _count_by_type('equipment')
machine_count = _count_by_type('machine')
pc_count = _count_by_type('computer')
network_count = _count_by_type('network_device')
printer_count = _count_by_type('printer')
total = equipment_count + pc_count + network_count + printer_count
total = machine_count + pc_count + network_count + printer_count
# Count by status
status_counts = db.session.query(
@@ -52,17 +52,18 @@ def get_dashboard():
).limit(10).all()
return success_response({
# Fields expected by frontend
'totalmachines': total,
'totalequipment': equipment_count,
# Fields expected by frontend (totalmachines now means machines,
# totalassets is the grand total - renamed with the machines plugin)
'totalassets': total,
'totalmachines': machine_count,
'totalpc': pc_count,
'totalnetwork': network_count,
'totalprinter': printer_count,
'activemachines': status_dict.get('In Use', 0),
'activeassets': status_dict.get('In Use', 0),
'inrepair': status_dict.get('In Repair', 0),
# Structured data
'counts': {
'equipment': equipment_count,
'machines': machine_count,
'pcs': pc_count,
'networkdevices': network_count,
'printers': printer_count,

View File

@@ -1,153 +0,0 @@
"""Machine Types API endpoints - Full CRUD."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, current_user
from shopdb.extensions import db
from shopdb.core.models import MachineType
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
machinetypes_bp = Blueprint('machinetypes', __name__)
@machinetypes_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_machinetypes():
"""List all machine types with optional filtering."""
page, per_page = get_pagination_params(request)
query = MachineType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(MachineType.isactive == True)
if category := request.args.get('category'):
query = query.filter(MachineType.category == category)
if search := request.args.get('search'):
query = query.filter(MachineType.machinetype.ilike(f'%{search}%'))
query = query.order_by(MachineType.machinetype)
items, total = paginate_query(query, page, per_page)
data = [mt.to_dict() for mt in items]
return paginated_response(data, page, per_page, total)
@machinetypes_bp.route('/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
def get_machinetype(type_id: int):
"""Get a single machine type."""
mt = db.session.get(MachineType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Machine type with ID {type_id} not found',
http_code=404
)
return success_response(mt.to_dict())
@machinetypes_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_machinetype():
"""Create a new machine type."""
data = request.get_json()
if not data or not data.get('machinetype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'machinetype is required')
if MachineType.query.filter_by(machinetype=data['machinetype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Machine type '{data['machinetype']}' already exists",
http_code=409
)
mt = MachineType(
machinetype=data['machinetype'],
category=data.get('category', 'Equipment'),
description=data.get('description'),
icon=data.get('icon')
)
db.session.add(mt)
db.session.commit()
return success_response(mt.to_dict(), message='Machine type created', http_code=201)
@machinetypes_bp.route('/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_machinetype(type_id: int):
"""Update a machine type."""
mt = db.session.get(MachineType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Machine type with ID {type_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Check duplicate name
if 'machinetype' in data and data['machinetype'] != mt.machinetype:
if MachineType.query.filter_by(machinetype=data['machinetype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Machine type '{data['machinetype']}' already exists",
http_code=409
)
for key in ['machinetype', 'category', 'description', 'icon', 'isactive']:
if key in data:
setattr(mt, key, data[key])
db.session.commit()
return success_response(mt.to_dict(), message='Machine type updated')
@machinetypes_bp.route('/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_machinetype(type_id: int):
"""Delete (deactivate) a machine type."""
mt = db.session.get(MachineType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Machine type with ID {type_id} not found',
http_code=404
)
# Check if any model uses this type
from shopdb.core.models import Model
if Model.query.filter_by(machinetypeid=type_id).first():
return error_response(
ErrorCodes.CONFLICT,
'Cannot delete machine type: models are using it',
http_code=409
)
mt.isactive = False
db.session.commit()
return success_response(message='Machine type deleted')

View File

@@ -1,4 +1,4 @@
"""Models (equipment models) API endpoints - Full CRUD."""
"""Models (vendor model catalog) API endpoints - Full CRUD."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
@@ -21,7 +21,7 @@ models_bp = Blueprint('models', __name__)
@models_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_models():
"""List all equipment models."""
"""List all vendor catalog models."""
page, per_page = get_pagination_params(request)
query = Model.query
@@ -32,8 +32,8 @@ def list_models():
if vendor_id := request.args.get('vendor', type=int):
query = query.filter(Model.vendorid == vendor_id)
if machinetype_id := request.args.get('machinetype', type=int):
query = query.filter(Model.machinetypeid == machinetype_id)
if modeltype_id := request.args.get('modeltype', type=int):
query = query.filter(Model.modeltypeid == modeltype_id)
if search := request.args.get('search'):
query = query.filter(Model.modelnumber.ilike(f'%{search}%'))
@@ -46,7 +46,7 @@ def list_models():
for m in items:
d = m.to_dict()
d['vendor'] = m.vendor.vendor if m.vendor else None
d['machinetype'] = m.machinetype.machinetype if m.machinetype else None
d['modeltype'] = m.modeltype.modeltype if m.modeltype else None
data.append(d)
return paginated_response(data, page, per_page, total)
@@ -67,7 +67,7 @@ def get_model(model_id: int):
data = m.to_dict()
data['vendor'] = m.vendor.to_dict() if m.vendor else None
data['machinetype'] = m.machinetype.to_dict() if m.machinetype else None
data['modeltype'] = m.modeltype.to_dict() if m.modeltype else None
return success_response(data)
@@ -97,7 +97,7 @@ def create_model():
m = Model(
modelnumber=data['modelnumber'],
vendorid=data.get('vendorid'),
machinetypeid=data.get('machinetypeid'),
modeltypeid=data.get('modeltypeid'),
description=data.get('description'),
imageurl=data.get('imageurl'),
documentationurl=data.get('documentationurl'),
@@ -128,7 +128,7 @@ def update_model(model_id: int):
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
for key in ['modelnumber', 'vendorid', 'machinetypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']:
for key in ['modelnumber', 'vendorid', 'modeltypeid', 'description', 'imageurl', 'documentationurl', 'notes', 'isactive']:
if key in data:
setattr(m, key, data[key])

View File

@@ -0,0 +1,157 @@
"""Model Types API endpoints - Full CRUD.
Types the vendor MODELS catalog (models.modeltypeid). Renamed from
machinetypes; the machines plugin now owns the "machinetypes" name.
"""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, current_user
from shopdb.extensions import db
from shopdb.core.models import ModelType
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_permission, require_role
modeltypes_bp = Blueprint('modeltypes', __name__)
@modeltypes_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_modeltypes():
"""List all model types with optional filtering."""
page, per_page = get_pagination_params(request)
query = ModelType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(ModelType.isactive == True)
if category := request.args.get('category'):
query = query.filter(ModelType.category == category)
if search := request.args.get('search'):
query = query.filter(ModelType.modeltype.ilike(f'%{search}%'))
query = query.order_by(ModelType.modeltype)
items, total = paginate_query(query, page, per_page)
data = [mt.to_dict() for mt in items]
return paginated_response(data, page, per_page, total)
@modeltypes_bp.route('/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
def get_modeltype(type_id: int):
"""Get a single model type."""
mt = db.session.get(ModelType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model type with ID {type_id} not found',
http_code=404
)
return success_response(mt.to_dict())
@modeltypes_bp.route('', methods=['POST'])
@jwt_required()
@require_role('admin')
def create_modeltype():
"""Create a new model type."""
data = request.get_json()
if not data or not data.get('modeltype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'modeltype is required')
if ModelType.query.filter_by(modeltype=data['modeltype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Model type '{data['modeltype']}' already exists",
http_code=409
)
mt = ModelType(
modeltype=data['modeltype'],
category=data.get('category', 'Equipment'),
description=data.get('description'),
icon=data.get('icon')
)
db.session.add(mt)
db.session.commit()
return success_response(mt.to_dict(), message='Model type created', http_code=201)
@modeltypes_bp.route('/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_role('admin')
def update_modeltype(type_id: int):
"""Update a model type."""
mt = db.session.get(ModelType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model type with ID {type_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Check duplicate name
if 'modeltype' in data and data['modeltype'] != mt.modeltype:
if ModelType.query.filter_by(modeltype=data['modeltype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Model type '{data['modeltype']}' already exists",
http_code=409
)
for key in ['modeltype', 'category', 'description', 'icon', 'isactive']:
if key in data:
setattr(mt, key, data[key])
db.session.commit()
return success_response(mt.to_dict(), message='Model type updated')
@modeltypes_bp.route('/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_role('admin')
def delete_modeltype(type_id: int):
"""Delete (deactivate) a model type."""
mt = db.session.get(ModelType, type_id)
if not mt:
return error_response(
ErrorCodes.NOT_FOUND,
f'Model type with ID {type_id} not found',
http_code=404
)
# Check if any model uses this type
from shopdb.core.models import Model
if Model.query.filter_by(modeltypeid=type_id).first():
return error_response(
ErrorCodes.CONFLICT,
'Cannot delete model type: models are using it',
http_code=409
)
mt.isactive = False
db.session.commit()
return success_response(message='Model type deleted')

View File

@@ -27,37 +27,37 @@ def generate_csv(data: list, columns: list) -> str:
# =============================================================================
# Report: Equipment by Type
# Report: Machines by Type
# =============================================================================
@reports_bp.route('/equipment-by-type', methods=['GET'])
@reports_bp.route('/machines-by-type', methods=['GET'])
@jwt_required(optional=True)
def equipment_by_type():
def machines_by_type():
"""
Report: Equipment count grouped by equipment type.
Report: Machine count grouped by machine type.
Query parameters:
- businessunitid: Filter by business unit
- format: 'json' (default) or 'csv'
"""
try:
from plugins.equipment.models import Equipment, EquipmentType
from plugins.machines.models import Machine, MachineType
except ImportError:
return error_response(
ErrorCodes.NOT_FOUND,
'Equipment plugin not installed',
'Machines plugin not installed',
http_code=404
)
query = db.session.query(
EquipmentType.equipmenttype,
EquipmentType.description,
db.func.count(Equipment.equipmentid).label('count')
).outerjoin(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
).outerjoin(Asset, Asset.assetid == Equipment.assetid
MachineType.machinetype,
MachineType.description,
db.func.count(Machine.machineid).label('count')
).outerjoin(Machine, Machine.machinetypeid == MachineType.machinetypeid
).outerjoin(Asset, Asset.assetid == Machine.assetid
).filter(
db.or_(Asset.isactive == True, Asset.assetid.is_(None)),
EquipmentType.isactive == True
MachineType.isactive == True
)
# Business unit filter
@@ -65,25 +65,25 @@ def equipment_by_type():
query = query.filter(Asset.businessunitid == int(bu_id))
query = query.group_by(
EquipmentType.equipmenttypeid,
EquipmentType.equipmenttype,
EquipmentType.description
).order_by(EquipmentType.equipmenttype)
MachineType.machinetypeid,
MachineType.machinetype,
MachineType.description
).order_by(MachineType.machinetype)
results = query.all()
data = [{'equipmenttype': r.equipmenttype, 'description': r.description or '', 'count': r.count} for r in results]
data = [{'machinetype': r.machinetype, 'description': r.description or '', 'count': r.count} for r in results]
total = sum(r['count'] for r in data)
if request.args.get('format') == 'csv':
csv_data = generate_csv(data, ['equipmenttype', 'description', 'count'])
csv_data = generate_csv(data, ['machinetype', 'description', 'count'])
return Response(
csv_data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=equipment_by_type.csv'}
headers={'Content-Disposition': 'attachment; filename=machines_by_type.csv'}
)
return success_response({
'report': 'equipment_by_type',
'report': 'machines_by_type',
'generated': datetime.now(timezone.utc).replace(tzinfo=None).isoformat(),
'data': data,
'total': total
@@ -450,12 +450,12 @@ def pc_relationships():
Report: PCs with relationships to shop floor machines.
Returns machine number, vendor, model, PC hostname, and PC IP address
for all active PC-equipment relationships.
for all active PC-machine relationships.
Query parameters:
- format: 'json' (default) or 'csv'
"""
# Asset relationships where a computer (source) relates to an equipment
# Asset relationships where a computer (source) relates to a machine
# (target) - the asset-model equivalent of the legacy PC->machine links.
sql = db.text("""
SELECT
@@ -468,7 +468,7 @@ def pc_relationships():
JOIN assets pc ON ar.sourceassetid = pc.assetid
JOIN computers cpc ON cpc.assetid = pc.assetid
JOIN assets eq ON ar.targetassetid = eq.assetid
JOIN equipment eqx ON eqx.assetid = eq.assetid
JOIN machines eqx ON eqx.assetid = eq.assetid
LEFT JOIN communications c ON c.assetid = pc.assetid AND c.isprimary = 1 AND c.comtypeid = 1
LEFT JOIN models mo ON eqx.modelnumberid = mo.modelnumberid
LEFT JOIN vendors v ON mo.vendorid = v.vendorid
@@ -508,10 +508,10 @@ def list_reports():
"""List all available reports."""
reports = [
{
'id': 'equipment-by-type',
'id': 'machines-by-type',
'name': 'Machines by Type',
'description': 'Machine count grouped by machine type',
'endpoint': '/api/reports/equipment-by-type',
'endpoint': '/api/reports/machines-by-type',
'category': 'inventory'
},
{

View File

@@ -110,8 +110,8 @@ def _get_asset_result(asset, query, relevance=None):
asset_type_name = asset.assettype.assettype if asset.assettype else 'asset'
plugin_id = asset.assetid
if asset_type_name == 'equipment' and hasattr(asset, 'equipment') and asset.equipment:
plugin_id = asset.equipment.equipmentid
if asset_type_name == 'machine' and hasattr(asset, 'machine') and asset.machine:
plugin_id = asset.machine.machineid
elif asset_type_name == 'computer' and hasattr(asset, 'computer') and asset.computer:
plugin_id = asset.computer.computerid
elif asset_type_name == 'network_device' and hasattr(asset, 'network_device') and asset.network_device:
@@ -120,7 +120,7 @@ def _get_asset_result(asset, query, relevance=None):
plugin_id = asset.printer.printerid
url_map = {
'equipment': f"/machines/{plugin_id}",
'machine': f"/machines/{plugin_id}",
'computer': f"/pcs/{plugin_id}",
'network_device': f"/network/{plugin_id}",
'printer': f"/printers/{plugin_id}",
@@ -478,21 +478,21 @@ def _search_notifications(query, search_term):
def _search_vendor_model_type(query, search_term):
"""Search assets by vendor name, model name, or equipment/device type name."""
"""Search assets by vendor name, model name, or machine/device type name."""
results = []
# Equipment: vendor, model, equipmenttype
# Machines: vendor, model, machinetype
try:
_require_enabled('equipment')
from plugins.equipment.models import Equipment, EquipmentType
equipment_assets = db.session.query(Asset).join(
Equipment, Equipment.assetid == Asset.assetid
_require_enabled('machines')
from plugins.machines.models import Machine, MachineType
machine_assets = db.session.query(Asset).join(
Machine, Machine.assetid == Asset.assetid
).outerjoin(
Vendor, Equipment.vendorid == Vendor.vendorid
Vendor, Machine.vendorid == Vendor.vendorid
).outerjoin(
Model, Equipment.modelnumberid == Model.modelnumberid
Model, Machine.modelnumberid == Model.modelnumberid
).outerjoin(
EquipmentType, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
MachineType, Machine.machinetypeid == MachineType.machinetypeid
).options(
joinedload(Asset.assettype),
joinedload(Asset.location),
@@ -501,16 +501,16 @@ def _search_vendor_model_type(query, search_term):
db.or_(
Vendor.vendor.ilike(search_term),
Model.modelnumber.ilike(search_term),
EquipmentType.equipmenttype.ilike(search_term)
MachineType.machinetype.ilike(search_term)
)
).limit(10).all()
for asset in equipment_assets:
for asset in machine_assets:
results.append(_get_asset_result(asset, query, 30))
except ImportError:
pass
except Exception as e:
logger.error(f"Equipment vendor/model/type search failed: {e}")
logger.error(f"Machine vendor/model/type search failed: {e}")
# Printers: vendor, model, printertype
try:

View File

@@ -53,7 +53,7 @@ IDENTIFIER_LABELS = {
'maintenancereference': 'Maintenance Reference',
'fqdn': 'FQDN / hostname',
}
IDENTIFIER_ASSETTYPES = ['equipment', 'computer', 'printer', 'network_device']
IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device']
# Global-search result types that can be toggled on/off independently of whether
# the owning plugin is enabled. Keys match the `type` field on search results;
@@ -63,7 +63,7 @@ SEARCH_DOMAINS = {
'application': 'Applications',
'knowledgebase': 'Knowledge Base',
'employee': 'Employees',
'equipment': 'Equipment',
'machine': 'Machines',
'computer': 'PCs',
'printer': 'Printers',
'network_device': 'Network Devices',
@@ -450,7 +450,7 @@ def build_default_settings():
'value': '/ge-aerospace-logo.svg',
'valuetype': 'string',
'category': 'branding',
'description': 'Logo shown on the equipment badge print page'
'description': 'Logo shown on the machine badge print page'
},
{
'key': 'site_favicon',

View File

@@ -2,7 +2,7 @@
from .base import BaseModel, SoftDeleteMixin, AuditMixin
from .asset import Asset, AssetType, AssetStatus
from .machine import MachineType
from .modeltype import ModelType
from .vendor import Vendor
from .model import Model
from .businessunit import BusinessUnit
@@ -26,8 +26,8 @@ __all__ = [
'Asset',
'AssetType',
'AssetStatus',
# Legacy machine type lookup (still referenced by models.machinetypeid)
'MachineType',
# Model-type lookup (referenced by models.modeltypeid)
'ModelType',
# Reference
'Vendor',
'Model',

View File

@@ -9,7 +9,7 @@ class AssetType(BaseModel):
Registry of asset categories.
Each type maps to a plugin-owned extension table.
Examples: equipment, computer, network_device, printer
Examples: machine, computer, network_device, printer
"""
__tablename__ = 'assettypes'
@@ -18,7 +18,7 @@ class AssetType(BaseModel):
db.String(50),
unique=True,
nullable=False,
comment='Category name: equipment, computer, network_device, printer'
comment='Category name: machine, computer, network_device, printer'
)
pluginname = db.Column(
db.String(100),
@@ -56,7 +56,7 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
Core asset model - minimal shared fields.
Category-specific data lives in plugin extension tables
(equipment, computers, network_devices, printers).
(machines, computers, network_devices, printers).
The assetid matches original machineid for migration compatibility.
"""
__tablename__ = 'assets'
@@ -79,7 +79,7 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
db.String(50),
index=True,
comment='Gauge lab asset reference (authoritative tag the gauge lab '
'assigns to equipment); distinct from assetnumber'
'assigns to machines); distinct from assetnumber'
)
maintenancereference = db.Column(
db.String(50),
@@ -226,8 +226,8 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
result['businessunitname'] = self.businessunit.businessunit
# Add plugin-specific ID for navigation purposes
if hasattr(self, 'equipment') and self.equipment:
result['pluginid'] = self.equipment.equipmentid
if hasattr(self, 'machine') and self.machine:
result['pluginid'] = self.machine.machineid
elif hasattr(self, 'computer') and self.computer:
result['pluginid'] = self.computer.computerid
elif hasattr(self, 'network_device') and self.network_device:
@@ -259,9 +259,9 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
def _get_extension_data(self):
"""Get category-specific data from extension table."""
# Check for equipment extension
if hasattr(self, 'equipment') and self.equipment:
return self.equipment.to_dict()
# Check for machine extension
if hasattr(self, 'machine') and self.machine:
return self.machine.to_dict()
# Check for computer extension
if hasattr(self, 'computer') and self.computer:
return self.computer.to_dict()

View File

@@ -1,6 +1,6 @@
"""Custom fields: site-defined extra attributes per asset type.
A CustomField is a definition scoped to one asset type (equipment, computer,
A CustomField is a definition scoped to one asset type (machine, computer,
printer, network_device). A CustomFieldValue holds one asset's value for one
field. This is the generic form of the built-in identifier columns - sites add
their own attributes without a schema change.

View File

@@ -1,31 +0,0 @@
"""Legacy machine type lookup.
The Machine instance model and its PC/status lookups were retired (ADR-001);
assets are the platform contract. MachineType is kept only because the shared
`models` table still references it via models.machinetypeid.
"""
from shopdb.extensions import db
from .base import BaseModel
class MachineType(BaseModel):
"""
Machine type classification.
Categories: Equipment, PC, Network, Printer
"""
__tablename__ = 'machinetypes'
machinetypeid = db.Column(db.Integer, primary_key=True)
machinetype = db.Column(db.String(100), unique=True, nullable=False)
category = db.Column(
db.String(50),
nullable=False,
default='Equipment',
comment='Equipment, PC, Network, or Printer'
)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
return f"<MachineType {self.machinetype}>"

View File

@@ -1,20 +1,20 @@
"""Model (equipment model number) model."""
"""Model (vendor catalog model number) model."""
from shopdb.extensions import db
from .base import BaseModel
class Model(BaseModel):
"""Equipment/device model information."""
"""Vendor catalog model information (machines, PCs, printers, network)."""
__tablename__ = 'models'
modelnumberid = db.Column(db.Integer, primary_key=True)
modelnumber = db.Column(db.String(100), nullable=False)
# Link to machine type (what kind of equipment this model is for)
machinetypeid = db.Column(
# Link to model type (what kind of thing this catalog model is for)
modeltypeid = db.Column(
db.Integer,
db.ForeignKey('machinetypes.machinetypeid'),
db.ForeignKey('modeltypes.modeltypeid'),
nullable=True
)
@@ -31,7 +31,7 @@ class Model(BaseModel):
notes = db.Column(db.Text)
# Relationships
machinetype = db.relationship('MachineType', backref='models')
modeltype = db.relationship('ModelType', backref='models')
vendor = db.relationship('Vendor', backref='models')
# Unique constraint on modelnumber + vendor

View File

@@ -0,0 +1,33 @@
"""Model-type lookup (types the vendor MODELS catalog).
The Machine instance model and its PC/status lookups were retired (ADR-001);
assets are the platform contract. ModelType is kept because the shared `models`
table references it via models.modeltypeid: it types vendor models (Lathe,
Switch, Laser Printer), not asset instances. Renamed from MachineType to be
role-accurate and to free the "machinetype" name for the machines plugin.
"""
from shopdb.extensions import db
from .base import BaseModel
class ModelType(BaseModel):
"""
Model-type classification (what kind of thing a catalog model is for).
Categories: Equipment, PC, Network, Printer
"""
__tablename__ = 'modeltypes'
modeltypeid = db.Column(db.Integer, primary_key=True)
modeltype = db.Column(db.String(100), unique=True, nullable=False)
category = db.Column(
db.String(50),
nullable=False,
default='Equipment',
comment='Equipment, PC, Network, or Printer'
)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
return f"<ModelType {self.modeltype}>"

View File

@@ -50,9 +50,9 @@ class AssetRelationship(BaseModel):
Relationships between assets.
Examples:
- Computer controls Equipment
- Computer controls Machine
- Two machines are dualpath partners
- Network device connects to equipment
- Network device connects to machine
"""
__tablename__ = 'assetrelationships'

View File

@@ -40,11 +40,11 @@ class Permission(db.Model):
('assets.create', 'Create assets', 'assets'),
('assets.edit', 'Edit assets', 'assets'),
('assets.delete', 'Delete assets', 'assets'),
# Equipment
('equipment.view', 'View equipment', 'equipment'),
('equipment.create', 'Create equipment', 'equipment'),
('equipment.edit', 'Edit equipment', 'equipment'),
('equipment.delete', 'Delete equipment', 'equipment'),
# Machines
('machines.view', 'View machines', 'machines'),
('machines.create', 'Create machines', 'machines'),
('machines.edit', 'Edit machines', 'machines'),
('machines.delete', 'Delete machines', 'machines'),
# Computers
('computers.view', 'View computers', 'computers'),
('computers.create', 'Create computers', 'computers'),

View File

@@ -1,7 +1,7 @@
"""Shared Alembic env.py logic for bundled plugins.
Every bundled plugin that owns tables (computers, employees, equipment,
knowledgebase, network, notifications, printers, slides, usb, warranty) has a
Every bundled plugin that owns tables (computers, employees, knowledgebase,
machines, network, notifications, printers, slides, usb, warranty) has a
`migrations/env.py` that does the minimum:
import os
@@ -42,8 +42,8 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'computers': ('computertypes', 'computers', 'computerinstalledapps',
'accessprotocols', 'computeraccess'),
'employees': ('directoryemployees',),
'equipment': ('equipmenttypes', 'equipment'),
'knowledgebase': ('knowledgebase',),
'machines': ('machinetypes', 'machines'),
'measuringtools': ('measuringtooltypes', 'measuringtools'),
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
'notifications': ('notificationtypes', 'notifications'),

View File

@@ -41,6 +41,24 @@ class PluginRegistry:
except (json.JSONDecodeError, TypeError):
# Corrupted file, start fresh
self._plugins = {}
self._migrate_renamed_plugins()
def _migrate_renamed_plugins(self) -> None:
"""Upgrade path for the equipment -> machines plugin rename.
Existing installs carry an 'equipment' entry in plugins.json. When the
renamed plugins/machines directory exists and no 'machines' entry does,
carry the state over under the new name and persist.
"""
if 'equipment' not in self._plugins or 'machines' in self._plugins:
return
machines_dir = Path(__file__).resolve().parents[2] / 'plugins' / 'machines'
if not machines_dir.exists():
return
state = self._plugins.pop('equipment')
state.name = 'machines'
self._plugins['machines'] = state
self._save()
def _save(self) -> None:
"""Save registry to file."""

View File

@@ -20,7 +20,7 @@ def _report_ids(client, headers):
def test_reports_include_core_reports(client, auth_headers):
"""The static core reports are always listed."""
_, ids = _report_ids(client, auth_headers)
assert 'equipment-by-type' in ids
assert 'machines-by-type' in ids
assert 'pc-relationships' in ids

View File

@@ -17,7 +17,7 @@ from shopdb.plugins import plugin_manager
from shopdb.plugins.base import BasePlugin, PluginMeta
BUNDLED_PLUGINS = ('computers', 'employees', 'equipment', 'knowledgebase', 'network', 'notifications', 'printers', 'slides', 'usb')
BUNDLED_PLUGINS = ('computers', 'employees', 'knowledgebase', 'machines', 'network', 'notifications', 'printers', 'slides', 'usb')
@pytest.fixture

View File

@@ -37,7 +37,7 @@ TABLE_OWNING_PLUGINS = tuple(sorted(PLUGIN_TABLE_OWNERS))
# no-op assertion must not apply to them. This is a frozen list on purpose - a
# newly discovered plugin does not silently get treated as a cutover no-op.
CUTOVER_PLUGINS = (
'computers', 'employees', 'equipment', 'knowledgebase', 'network',
'computers', 'employees', 'knowledgebase', 'machines', 'network',
'notifications', 'printers', 'slides', 'usb', 'warranty',
)
@@ -46,6 +46,9 @@ CUTOVER_PLUGINS = (
# stamp '<plugin>0001anchor'; measuringtools stamps its real baseline id.
EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS}
EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
# machines (renamed from equipment) keeps its original anchor id and adds the
# rename revision on top, so its head is not the f-string default.
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
# Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned.

View File

@@ -95,8 +95,8 @@ def test_plugin_loader_discovers_bundled_plugins(app):
expected_plugins = {
'computers',
'employees',
'equipment',
'knowledgebase',
'machines',
'network',
'notifications',
'printers',