Compare commits
6 Commits
lab-stage-
...
lab-stage-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ed3da1b64 | ||
|
|
d6a78a72ff | ||
|
|
6dfc8906c4 | ||
|
|
cb367a38f9 | ||
|
|
d1c844d533 | ||
|
|
f5cfac33b4 |
@@ -1126,3 +1126,44 @@ export const measuringtoolsApi = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3D printed parts (printedparts plugin)
|
||||
export const printedpartsApi = {
|
||||
list(params = {}) {
|
||||
return api.get('/printedparts/items', { params })
|
||||
},
|
||||
get(printeditemid) {
|
||||
return api.get(`/printedparts/items/${printeditemid}`)
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/printedparts/items', data)
|
||||
},
|
||||
update(printeditemid, data) {
|
||||
return api.put(`/printedparts/items/${printeditemid}`, data)
|
||||
},
|
||||
remove(printeditemid) {
|
||||
return api.delete(`/printedparts/items/${printeditemid}`)
|
||||
},
|
||||
uploadImage(printeditemid, file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return api.post(`/printedparts/items/${printeditemid}/image`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
deleteImage(printeditemid) {
|
||||
return api.delete(`/printedparts/items/${printeditemid}/image`)
|
||||
},
|
||||
restock(printeditemid, data) {
|
||||
return api.post(`/printedparts/items/${printeditemid}/restock`, data)
|
||||
},
|
||||
adjust(printeditemid, data) {
|
||||
return api.post(`/printedparts/items/${printeditemid}/adjust`, data)
|
||||
},
|
||||
kioskItem(itemcode) {
|
||||
return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`)
|
||||
},
|
||||
kioskTake(data) {
|
||||
return api.post('/printedparts/kiosk/take', data)
|
||||
}
|
||||
}
|
||||
|
||||
38
frontend/src/components/TouchKeypad.vue
Normal file
38
frontend/src/components/TouchKeypad.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="touch-keypad">
|
||||
<button v-for="digit in digits" :key="digit" type="button"
|
||||
class="keypad-button" @click="$emit('digit', digit)">
|
||||
{{ digit }}
|
||||
</button>
|
||||
<button type="button" class="keypad-button keypad-muted"
|
||||
@click="$emit('clear')">C</button>
|
||||
<button type="button" class="keypad-button" @click="$emit('digit', '0')">0</button>
|
||||
<button type="button" class="keypad-button keypad-muted"
|
||||
@click="$emit('backspace')"><</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9']
|
||||
defineEmits(['digit', 'clear', 'backspace'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.touch-keypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.6rem;
|
||||
max-width: 20rem;
|
||||
}
|
||||
.keypad-button {
|
||||
font-size: 1.8rem;
|
||||
padding: 1rem 0;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.keypad-button:active { background: var(--primary); color: #fff; }
|
||||
.keypad-muted { color: var(--text-light); }
|
||||
</style>
|
||||
@@ -65,6 +65,14 @@ const routes = [
|
||||
name: 'shopfloor',
|
||||
component: () => import('../views/ShopfloorDashboard.vue')
|
||||
},
|
||||
{
|
||||
// Touch kiosk for taking 3D-printed parts: scan bin, scan badge, keypad.
|
||||
// Open on purpose - see the decision record in the printedparts proposal.
|
||||
path: '/parts-kiosk',
|
||||
name: 'parts-kiosk',
|
||||
component: () => import('../views/printedparts/PartsKiosk.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: '/tv',
|
||||
name: 'tv',
|
||||
|
||||
@@ -11,25 +11,25 @@ export default [
|
||||
{
|
||||
path: 'printedparts',
|
||||
name: 'printedparts',
|
||||
component: () => import('../../views/printedparts/PrintedpartsList.vue'),
|
||||
component: () => import('../../views/printedparts/PrintedItemsList.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/new',
|
||||
name: 'printedparts-new',
|
||||
component: () => import('../../views/printedparts/PrintedpartsForm.vue'),
|
||||
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/:id',
|
||||
name: 'printedparts-detail',
|
||||
component: () => import('../../views/printedparts/PrintedpartsDetail.vue'),
|
||||
component: () => import('../../views/printedparts/PrintedItemDetail.vue'),
|
||||
meta: { plugin: 'printedparts' }
|
||||
},
|
||||
{
|
||||
path: 'printedparts/:id/edit',
|
||||
name: 'printedparts-edit',
|
||||
component: () => import('../../views/printedparts/PrintedpartsForm.vue'),
|
||||
component: () => import('../../views/printedparts/PrintedItemForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printedparts' }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -101,7 +101,7 @@ import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
||||
KeyRound, LogOut
|
||||
Box, KeyRound, LogOut
|
||||
} from 'lucide-vue-next'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||
@@ -147,6 +147,7 @@ const iconMap = {
|
||||
'image': Image,
|
||||
'shield': ShieldCheck,
|
||||
'ruler': Ruler,
|
||||
'box': Box,
|
||||
}
|
||||
|
||||
// Default navigation (used as fallback if API fails)
|
||||
|
||||
242
frontend/src/views/printedparts/PartsKiosk.vue
Normal file
242
frontend/src/views/printedparts/PartsKiosk.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div class="parts-kiosk" @click="focusWedge">
|
||||
<!-- keyboard-wedge scanners type the code + Enter into this hidden,
|
||||
always-focused input; whichever step is active consumes the scan -->
|
||||
<input ref="wedgeInput" v-model="wedgeBuffer" class="wedge-input"
|
||||
autocomplete="off" @keydown.enter.prevent="onWedgeEnter" />
|
||||
|
||||
<header class="kiosk-header">
|
||||
<h1>3D Printed Parts</h1>
|
||||
<button v-if="step !== 'item'" class="btn btn-secondary" @click="reset">
|
||||
Start over
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="kiosk-error">{{ error }}</div>
|
||||
|
||||
<!-- step 1: scan the bin -->
|
||||
<section v-if="step === 'item'" class="kiosk-step">
|
||||
<p class="kiosk-prompt">Scan the barcode on the bin</p>
|
||||
<p class="kiosk-hint">
|
||||
No scanner?
|
||||
<a href="#" @click.prevent="manualEntry = !manualEntry">Type the code</a>
|
||||
</p>
|
||||
<div v-if="manualEntry" class="manual-row">
|
||||
<input v-model="manualCode" class="form-control" placeholder="3DP-0001"
|
||||
@keydown.enter="lookupItem(manualCode)" />
|
||||
<button class="btn btn-primary" @click="lookupItem(manualCode)">Go</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- step 2: badge -->
|
||||
<section v-else-if="step === 'badge'" class="kiosk-step">
|
||||
<div class="item-card">
|
||||
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
|
||||
<div>
|
||||
<h2>{{ item.itemname }}</h2>
|
||||
<p class="kiosk-hint">{{ item.itemcode }} - {{ item.quantityonhand }} on hand</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="kiosk-prompt">Scan your badge</p>
|
||||
<div class="manual-row">
|
||||
<input v-model="manualBadge" class="form-control" placeholder="or type your SSO"
|
||||
@keydown.enter="acceptBadge(manualBadge)" />
|
||||
<button class="btn btn-primary" @click="acceptBadge(manualBadge)">Next</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- step 3: quantity -->
|
||||
<section v-else-if="step === 'quantity'" class="kiosk-step">
|
||||
<div class="item-card">
|
||||
<img v-if="item.imageurl" :src="withBase(item.imageurl)" class="item-photo" />
|
||||
<div>
|
||||
<h2>{{ item.itemname }}</h2>
|
||||
<p class="kiosk-hint">{{ item.quantityonhand }} on hand</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="kiosk-prompt">How many are you taking?</p>
|
||||
<div class="quantity-display">{{ quantity || '0' }}</div>
|
||||
<TouchKeypad @digit="quantity += $event"
|
||||
@clear="quantity = ''"
|
||||
@backspace="quantity = quantity.slice(0, -1)" />
|
||||
<button class="btn btn-primary take-button" :disabled="!quantity || submitting"
|
||||
@click="submitTake">
|
||||
{{ submitting ? 'Working...' : 'TAKE' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- done -->
|
||||
<section v-else-if="step === 'done'" class="kiosk-step">
|
||||
<p class="kiosk-success">Done - {{ doneMessage }}</p>
|
||||
<p class="kiosk-hint">Starting over in a few seconds...</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { printedpartsApi } from '../../api'
|
||||
import { withBase } from '../../utils/basePath'
|
||||
import TouchKeypad from '../../components/TouchKeypad.vue'
|
||||
|
||||
const step = ref('item')
|
||||
const item = ref(null)
|
||||
const badge = ref('')
|
||||
const quantity = ref('')
|
||||
const error = ref('')
|
||||
const doneMessage = ref('')
|
||||
const submitting = ref(false)
|
||||
const manualEntry = ref(false)
|
||||
const manualCode = ref('')
|
||||
const manualBadge = ref('')
|
||||
const wedgeInput = ref(null)
|
||||
const wedgeBuffer = ref('')
|
||||
let resetTimer = null
|
||||
|
||||
onMounted(focusWedge)
|
||||
onBeforeUnmount(() => clearTimeout(resetTimer))
|
||||
|
||||
function focusWedge() {
|
||||
wedgeInput.value?.focus()
|
||||
}
|
||||
|
||||
function onWedgeEnter() {
|
||||
const scanned = wedgeBuffer.value.trim()
|
||||
wedgeBuffer.value = ''
|
||||
if (!scanned) return
|
||||
if (step.value === 'item') lookupItem(scanned)
|
||||
else if (step.value === 'badge') acceptBadge(scanned)
|
||||
}
|
||||
|
||||
async function lookupItem(itemcode) {
|
||||
error.value = ''
|
||||
if (!itemcode) return
|
||||
try {
|
||||
const response = await printedpartsApi.kioskItem(itemcode.trim())
|
||||
item.value = response.data.data
|
||||
step.value = 'badge'
|
||||
manualEntry.value = false
|
||||
manualCode.value = ''
|
||||
} catch (lookupError) {
|
||||
error.value = lookupError.response?.data?.data?.error?.message ||
|
||||
'No part matches that barcode'
|
||||
}
|
||||
focusWedge()
|
||||
}
|
||||
|
||||
function acceptBadge(value) {
|
||||
error.value = ''
|
||||
const scanned = (value || '').trim()
|
||||
if (!scanned) return
|
||||
badge.value = scanned
|
||||
manualBadge.value = ''
|
||||
step.value = 'quantity'
|
||||
focusWedge()
|
||||
}
|
||||
|
||||
async function submitTake() {
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await printedpartsApi.kioskTake({
|
||||
itemcode: item.value.itemcode,
|
||||
badge: badge.value,
|
||||
quantity: parseInt(quantity.value, 10)
|
||||
})
|
||||
doneMessage.value = response.data.message
|
||||
step.value = 'done'
|
||||
resetTimer = setTimeout(reset, 4000)
|
||||
} catch (takeError) {
|
||||
error.value = takeError.response?.data?.data?.error?.message ||
|
||||
'Could not complete - see the parts team'
|
||||
if (takeError.response?.status === 422) {
|
||||
// badge problem: go back a step so the next scan retries cleanly
|
||||
step.value = 'badge'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
clearTimeout(resetTimer)
|
||||
step.value = 'item'
|
||||
item.value = null
|
||||
badge.value = ''
|
||||
quantity.value = ''
|
||||
error.value = ''
|
||||
doneMessage.value = ''
|
||||
focusWedge()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.parts-kiosk {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.kiosk-header {
|
||||
width: 100%;
|
||||
max-width: 40rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.wedge-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
}
|
||||
.kiosk-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
max-width: 40rem;
|
||||
width: 100%;
|
||||
}
|
||||
.kiosk-prompt { font-size: 1.6rem; font-weight: 600; }
|
||||
.kiosk-hint { color: var(--text-light); }
|
||||
.kiosk-error {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.kiosk-success { font-size: 1.6rem; color: var(--success); font-weight: 600; }
|
||||
.item-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 1rem 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
.item-photo {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.quantity-display {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
min-width: 8rem;
|
||||
text-align: center;
|
||||
border-bottom: 3px solid var(--primary);
|
||||
}
|
||||
.take-button {
|
||||
font-size: 1.5rem;
|
||||
padding: 0.9rem 3.5rem;
|
||||
}
|
||||
.manual-row { display: flex; gap: 0.6rem; }
|
||||
</style>
|
||||
208
frontend/src/views/printedparts/PrintedItemDetail.vue
Normal file
208
frontend/src/views/printedparts/PrintedItemDetail.vue
Normal file
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="item">
|
||||
<div class="hero-card">
|
||||
<img v-if="item.imageurl" :src="withBase(item.imageurl)"
|
||||
:alt="item.itemname" class="hero-image" />
|
||||
<div class="hero-content">
|
||||
<h2 class="hero-title">{{ item.itemname }}</h2>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-secondary">{{ item.itemcode }}</span>
|
||||
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
|
||||
{{ item.quantityonhand }} on hand
|
||||
</span>
|
||||
<span v-if="item.islowstock" class="badge badge-warning">Low stock</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<p v-if="item.itemdescription">{{ item.itemdescription }}</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<button class="btn btn-primary btn-sm" @click="openLedger('restock')">
|
||||
Restock
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="openLedger('adjust')">
|
||||
Adjust
|
||||
</button>
|
||||
<router-link :to="`/printedparts/${item.printeditemid}/edit`"
|
||||
class="btn btn-secondary btn-sm">Edit</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Details</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Item code</span>
|
||||
<span class="info-value">{{ item.itemcode }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Bin location</span>
|
||||
<span class="info-value">{{ item.binlocation || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Quantity on hand</span>
|
||||
<span class="info-value">{{ item.quantityonhand }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Low-stock threshold</span>
|
||||
<span class="info-value">{{ item.lowstockthreshold }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="item.printnotes">
|
||||
<span class="info-label">Print notes</span>
|
||||
<span class="info-value">{{ item.printnotes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Recent transactions</h3>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Type</th>
|
||||
<th>Qty</th>
|
||||
<th>Who</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="transaction in item.recenttransactions"
|
||||
:key="transaction.transactionid">
|
||||
<td>{{ formatDate(transaction.transactiondate) }}</td>
|
||||
<td>{{ transaction.transactiontype }}</td>
|
||||
<td :class="transaction.quantitychange < 0 ? 'qty-out' : 'qty-in'">
|
||||
{{ transaction.quantitychange > 0 ? '+' : '' }}{{ transaction.quantitychange }}
|
||||
</td>
|
||||
<td>{{ transaction.employeename || transaction.employeesso }}</td>
|
||||
<td>{{ transaction.reason || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!item.recenttransactions?.length">
|
||||
<td colspan="5" class="empty-state">No transactions yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="audit-footer">
|
||||
Created {{ formatDate(item.createddate) }} -
|
||||
Modified {{ formatDate(item.modifieddate) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">Item not found</div>
|
||||
|
||||
<Modal v-model="ledgerOpen" :title="ledgerMode === 'restock' ? 'Restock' : 'Adjust count'">
|
||||
<div v-if="ledgerError" class="error-message">{{ ledgerError }}</div>
|
||||
<div class="form-group">
|
||||
<label>{{ ledgerMode === 'restock' ? 'Quantity printed' : 'Change (+/-)' }}</label>
|
||||
<input v-model.number="ledgerQuantity" type="number" class="form-control" />
|
||||
</div>
|
||||
<div v-if="ledgerMode === 'adjust'" class="form-group">
|
||||
<label>Reason *</label>
|
||||
<input v-model="ledgerReason" type="text" class="form-control"
|
||||
placeholder="e.g., damaged parts scrapped, recount" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Your badge / SSO *</label>
|
||||
<input v-model="ledgerBadge" type="text" class="form-control"
|
||||
placeholder="Scan badge or type SSO" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-primary" :disabled="ledgerSaving" @click="submitLedger">
|
||||
{{ ledgerSaving ? 'Saving...' : 'Submit' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="ledgerOpen = false">Cancel</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { printedpartsApi } from '../../api'
|
||||
import { withBase } from '../../utils/basePath'
|
||||
import Modal from '../../components/Modal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const item = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await printedpartsApi.get(route.params.id)
|
||||
item.value = response.data.data
|
||||
} catch (loadError) {
|
||||
console.error('Error loading printed item:', loadError)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const ledgerOpen = ref(false)
|
||||
const ledgerMode = ref('restock')
|
||||
const ledgerQuantity = ref(null)
|
||||
const ledgerReason = ref('')
|
||||
const ledgerBadge = ref('')
|
||||
const ledgerSaving = ref(false)
|
||||
const ledgerError = ref('')
|
||||
|
||||
function openLedger(mode) {
|
||||
ledgerMode.value = mode
|
||||
ledgerQuantity.value = null
|
||||
ledgerReason.value = ''
|
||||
ledgerBadge.value = ''
|
||||
ledgerError.value = ''
|
||||
ledgerOpen.value = true
|
||||
}
|
||||
|
||||
async function submitLedger() {
|
||||
ledgerSaving.value = true
|
||||
ledgerError.value = ''
|
||||
try {
|
||||
if (ledgerMode.value === 'restock') {
|
||||
await printedpartsApi.restock(item.value.printeditemid, {
|
||||
quantity: ledgerQuantity.value, badge: ledgerBadge.value
|
||||
})
|
||||
} else {
|
||||
await printedpartsApi.adjust(item.value.printeditemid, {
|
||||
quantitychange: ledgerQuantity.value,
|
||||
reason: ledgerReason.value,
|
||||
badge: ledgerBadge.value
|
||||
})
|
||||
}
|
||||
ledgerOpen.value = false
|
||||
const response = await printedpartsApi.get(item.value.printeditemid)
|
||||
item.value = response.data.data
|
||||
} catch (submitError) {
|
||||
ledgerError.value =
|
||||
submitError.response?.data?.data?.error?.message ||
|
||||
submitError.response?.data?.error?.message || 'Submit failed'
|
||||
} finally {
|
||||
ledgerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-actions { margin-top: 0.75rem; }
|
||||
.qty-out { color: var(--danger); }
|
||||
.qty-in { color: var(--success); }
|
||||
</style>
|
||||
170
frontend/src/views/printedparts/PrintedItemForm.vue
Normal file
170
frontend/src/views/printedparts/PrintedItemForm.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Part' : 'Add Part' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<form @submit.prevent="save">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Name *</label>
|
||||
<input v-model="form.itemname" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Bin location</label>
|
||||
<input v-model="form.binlocation" type="text" class="form-control"
|
||||
placeholder="e.g., Bin A3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<input v-model="form.itemdescription" type="text" class="form-control"
|
||||
maxlength="500" placeholder="Brief description shown on the storefront" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Low-stock threshold</label>
|
||||
<input v-model.number="form.lowstockthreshold" type="number" min="0"
|
||||
class="form-control" />
|
||||
</div>
|
||||
<div v-if="isEdit" class="form-group">
|
||||
<label>Item code</label>
|
||||
<input :value="itemcode" type="text" class="form-control" disabled />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Print notes</label>
|
||||
<textarea v-model="form.printnotes" class="form-control" rows="3"
|
||||
placeholder="Material, print time, slicer file path"></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="isEdit" class="form-group">
|
||||
<label>Photo</label>
|
||||
<div class="image-row">
|
||||
<img v-if="imageurl" :src="withBase(imageurl)" class="image-preview" />
|
||||
<input type="file" accept="image/*" @change="onImagePicked" />
|
||||
<button v-if="imageurl" type="button" class="btn btn-secondary btn-sm"
|
||||
@click="removeImage">Remove photo</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="form-hint">Save first, then add a photo from the edit page.</p>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<router-link :to="cancelTarget" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { printedpartsApi } from '../../api'
|
||||
import { withBase } from '../../utils/basePath'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const cancelTarget = computed(() =>
|
||||
isEdit.value ? `/printedparts/${route.params.id}` : '/printedparts')
|
||||
|
||||
const form = ref({
|
||||
itemname: '',
|
||||
itemdescription: '',
|
||||
lowstockthreshold: null,
|
||||
binlocation: '',
|
||||
printnotes: ''
|
||||
})
|
||||
const itemcode = ref('')
|
||||
const imageurl = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) return
|
||||
try {
|
||||
const response = await printedpartsApi.get(route.params.id)
|
||||
const item = response.data.data
|
||||
for (const key of Object.keys(form.value)) {
|
||||
form.value[key] = item[key]
|
||||
}
|
||||
itemcode.value = item.itemcode
|
||||
imageurl.value = item.imageurl
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load the item'
|
||||
console.error(loadError)
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const payload = { ...form.value }
|
||||
if (payload.lowstockthreshold === null || payload.lowstockthreshold === '') {
|
||||
delete payload.lowstockthreshold
|
||||
}
|
||||
let printeditemid
|
||||
if (isEdit.value) {
|
||||
await printedpartsApi.update(route.params.id, payload)
|
||||
printeditemid = route.params.id
|
||||
} else {
|
||||
const response = await printedpartsApi.create(payload)
|
||||
printeditemid = response.data.data.printeditemid
|
||||
}
|
||||
router.push(`/printedparts/${printeditemid}`)
|
||||
} catch (saveError) {
|
||||
error.value = saveError.response?.data?.error?.message || 'Save failed'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onImagePicked(event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const response = await printedpartsApi.uploadImage(route.params.id, file)
|
||||
imageurl.value = response.data.data.imageurl
|
||||
} catch (uploadError) {
|
||||
error.value = uploadError.response?.data?.error?.message || 'Image upload failed'
|
||||
}
|
||||
}
|
||||
|
||||
async function removeImage() {
|
||||
try {
|
||||
await printedpartsApi.deleteImage(route.params.id)
|
||||
imageurl.value = null
|
||||
} catch (deleteError) {
|
||||
error.value = 'Could not remove the image'
|
||||
console.error(deleteError)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.image-preview {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.form-hint { color: var(--text-light); }
|
||||
</style>
|
||||
141
frontend/src/views/printedparts/PrintedItemsList.vue
Normal file
141
frontend/src/views/printedparts/PrintedItemsList.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>3D Printed Parts</h2>
|
||||
<router-link to="/printedparts/new" class="btn btn-primary">Add Part</router-link>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search code, name, description, bin..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<label class="lowstock-filter">
|
||||
<input v-model="lowstockOnly" type="checkbox" @change="loadItems" />
|
||||
Low stock only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Code</th>
|
||||
<th>Name</th>
|
||||
<th>Quantity</th>
|
||||
<th>Bin</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.printeditemid"
|
||||
class="clickable-row"
|
||||
@click="$router.push(`/printedparts/${item.printeditemid}`)"
|
||||
>
|
||||
<td class="thumb-cell">
|
||||
<img
|
||||
v-if="item.imageurl"
|
||||
:src="withBase(item.imageurl)"
|
||||
:alt="item.itemname"
|
||||
class="item-thumb"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ item.itemcode || '-' }}</td>
|
||||
<td>{{ item.itemname }}</td>
|
||||
<td>
|
||||
<span :class="['badge', item.islowstock ? 'badge-danger' : 'badge-success']">
|
||||
{{ item.quantityonhand }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.binlocation || '-' }}</td>
|
||||
<td class="truncate-cell">{{ item.itemdescription || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="6" class="empty-state">No printed parts found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:total-pages="totalPages"
|
||||
@change="setPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { printedpartsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
import { withBase } from '../../utils/basePath'
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const lowstockOnly = ref(false)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadItems })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
if (lowstockOnly.value) params.lowstock = 'true'
|
||||
const response = await printedpartsApi.list(params)
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading printed parts:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => setSearch(search.value), 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item-thumb {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
object-fit: cover;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.thumb-cell { width: 3rem; }
|
||||
.truncate-cell {
|
||||
max-width: 20rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lowstock-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,149 +0,0 @@
|
||||
<template>
|
||||
<div class="detail-page" v-if="item">
|
||||
<div class="hero-card">
|
||||
<div class="hero-content">
|
||||
<div class="hero-title-row">
|
||||
<h1 class="hero-title">{{ item.name || item.assetnumber || 'Printedparts' }}</h1>
|
||||
<router-link
|
||||
v-if="authStore.isAuthenticated"
|
||||
:to="`/printedparts/${itemId}/edit`"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="detail-item" v-if="item.assetnumber">
|
||||
<span class="label">Asset #</span>
|
||||
<span class="value">{{ item.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="item.serialnumber">
|
||||
<span class="label">Serial</span>
|
||||
<span class="value mono">{{ item.serialnumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Printedparts Information</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Example Field</span>
|
||||
<span class="info-value">{{ item.examplefield || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Asset Information</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number</span>
|
||||
<span class="info-value">{{ item.assetnumber || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ item.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ item.serialnumber || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-bar" v-if="authStore.isAuthenticated">
|
||||
<router-link :to="`/printedparts/${itemId}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmDelete" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="loading-container">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="error-container">
|
||||
<p>Record not found</p>
|
||||
<router-link to="/printedparts" class="btn btn-secondary">Back to Printedparts</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '../../api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
// local api client. move into src/api/index.js (see
|
||||
// plugins/printedparts/frontend-api-snippet.js) then swap for:
|
||||
// import { printedpartsApi } from '../../api'
|
||||
const printedpartsApi = {
|
||||
list(params = {}) { return api.get('/printedparts', { params }) },
|
||||
get(itemId) { return api.get(`/printedparts/${itemId}`) },
|
||||
create(data) { return api.post('/printedparts', data) },
|
||||
update(itemId, data) { return api.put(`/printedparts/${itemId}`, data) },
|
||||
remove(itemId) { return api.delete(`/printedparts/${itemId}`) }
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const itemId = route.params.id
|
||||
const item = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(loadItem)
|
||||
|
||||
async function loadItem() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await printedpartsApi.get(itemId)
|
||||
item.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading printedparts:', error)
|
||||
item.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (confirm('Delete this record?')) {
|
||||
try {
|
||||
await printedpartsApi.remove(itemId)
|
||||
router.push('/printedparts')
|
||||
} catch (error) {
|
||||
console.error('Error deleting printedparts:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
@@ -1,228 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Printedparts' : 'Add Printedparts' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<form @submit.prevent="submitForm">
|
||||
<fieldset>
|
||||
<legend>Asset Information</legend>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="assetnumber">Asset Number *</label>
|
||||
<input
|
||||
id="assetnumber"
|
||||
v-model="form.assetnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number</label>
|
||||
<input
|
||||
id="serialnumber"
|
||||
v-model="form.serialnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Printedparts Details</legend>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="examplefield">Example Field</label>
|
||||
<input
|
||||
id="examplefield"
|
||||
v-model="form.examplefield"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : (isEdit ? 'Save Changes' : 'Create') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '../../api'
|
||||
|
||||
// local api client. move into src/api/index.js (see
|
||||
// plugins/printedparts/frontend-api-snippet.js) then swap for:
|
||||
// import { printedpartsApi } from '../../api'
|
||||
const printedpartsApi = {
|
||||
list(params = {}) { return api.get('/printedparts', { params }) },
|
||||
get(itemId) { return api.get(`/printedparts/${itemId}`) },
|
||||
create(data) { return api.post('/printedparts', data) },
|
||||
update(itemId, data) { return api.put(`/printedparts/${itemId}`, data) },
|
||||
remove(itemId) { return api.delete(`/printedparts/${itemId}`) }
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const itemId = route.params.id
|
||||
const isEdit = computed(() => !!itemId)
|
||||
|
||||
const form = ref({
|
||||
assetnumber: '',
|
||||
name: '',
|
||||
serialnumber: '',
|
||||
examplefield: ''
|
||||
})
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value) {
|
||||
await loadItem()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadItem() {
|
||||
try {
|
||||
const response = await printedpartsApi.get(itemId)
|
||||
const data = response.data.data
|
||||
form.value.assetnumber = data.assetnumber || ''
|
||||
form.value.name = data.name || ''
|
||||
form.value.serialnumber = data.serialnumber || ''
|
||||
form.value.examplefield = data.examplefield || ''
|
||||
} catch (loadError) {
|
||||
console.error('Error loading printedparts:', loadError)
|
||||
error.value = 'Failed to load record'
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
assetnumber: form.value.assetnumber,
|
||||
name: form.value.name || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
examplefield: form.value.examplefield || null
|
||||
}
|
||||
|
||||
let redirectId = itemId
|
||||
if (isEdit.value) {
|
||||
await printedpartsApi.update(itemId, payload)
|
||||
} else {
|
||||
const response = await printedpartsApi.create(payload)
|
||||
redirectId = response.data.data?.assetid
|
||||
}
|
||||
|
||||
router.push(redirectId ? `/printedparts/${redirectId}` : '/printedparts')
|
||||
} catch (submitError) {
|
||||
console.error('Error saving printedparts:', submitError)
|
||||
error.value = 'Failed to save record'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (isEdit.value) {
|
||||
router.push(`/printedparts/${itemId}`)
|
||||
} else {
|
||||
router.push('/printedparts')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
legend {
|
||||
font-weight: 600;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
margin-bottom: 0.375rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,142 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Printedparts</h2>
|
||||
<router-link to="/printedparts/new" class="btn btn-primary">Add Printedparts</router-link>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset Tag</th>
|
||||
<th>Name</th>
|
||||
<th>Example Field</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.assetid">
|
||||
<td>{{ item.assetnumber || '-' }}</td>
|
||||
<td>{{ item.name || '-' }}</td>
|
||||
<td>{{ item.examplefield || '-' }}</td>
|
||||
<td class="actions">
|
||||
<router-link
|
||||
:to="`/printedparts/${item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No printedparts records found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
|
||||
// local api client. move into src/api/index.js (see
|
||||
// plugins/printedparts/frontend-api-snippet.js) then swap for:
|
||||
// import { printedpartsApi } from '../../api'
|
||||
const printedpartsApi = {
|
||||
list(params = {}) { return api.get('/printedparts', { params }) },
|
||||
get(itemId) { return api.get(`/printedparts/${itemId}`) },
|
||||
create(data) { return api.post('/printedparts', data) },
|
||||
update(itemId, data) { return api.put(`/printedparts/${itemId}`, data) },
|
||||
remove(itemId) { return api.delete(`/printedparts/${itemId}`) }
|
||||
}
|
||||
|
||||
const items = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(25)
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(loadItems)
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
const response = await printedpartsApi.list(params)
|
||||
items.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading printedparts:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadItems()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(target) {
|
||||
if (target >= 1 && target <= totalPages.value) {
|
||||
page.value = target
|
||||
loadItems()
|
||||
}
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
page.value = 1
|
||||
loadItems()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters .form-control {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
</style>
|
||||
@@ -41,3 +41,19 @@ pytest plugins/printedparts/tests/
|
||||
- `docs/PLUGIN-QUICKSTART.md` - 30-minute walkthrough
|
||||
- `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract
|
||||
- `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules
|
||||
|
||||
## Why the kiosk take endpoint is unauthenticated
|
||||
|
||||
`POST /api/printedparts/kiosk/take` is the product's first open WRITE (every
|
||||
other kiosk endpoint is a read). Accepted deliberately, against the criteria
|
||||
in docs/proposals/printedparts-plugin.md:
|
||||
|
||||
1. Decrement-only: it can reduce stock of an active item, nothing else.
|
||||
2. Fully attributed: it refuses to act without a badge that resolves under
|
||||
the site policy; every action lands in the ledger with SSO + name + time.
|
||||
3. Bounded blast radius: worst case is stock counts driven low - visible in
|
||||
the ledger and reversible with an adjust.
|
||||
4. Physically rate-limited: it serves a touch screen on the shop floor;
|
||||
nothing enumerable, nothing worth scraping.
|
||||
|
||||
Any future open-write endpoint must clear the same bar.
|
||||
|
||||
@@ -1,45 +1,348 @@
|
||||
"""Printedparts plugin API routes."""
|
||||
"""Printedparts plugin API routes.
|
||||
|
||||
Reads are open (jwt optional) like every list surface; mutations arrive in
|
||||
later stages with permission gates. The kiosk endpoints (unauthenticated by
|
||||
explicit decision - see the proposal) also land later.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy import or_
|
||||
|
||||
from shopdb.api import (
|
||||
db,
|
||||
success_response,
|
||||
error_response,
|
||||
paginated_response,
|
||||
ErrorCodes,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
require_permission,
|
||||
)
|
||||
|
||||
from ..models import Printedparts
|
||||
|
||||
from ..models import PrintedItem
|
||||
|
||||
printedparts_bp = Blueprint('printedparts', __name__)
|
||||
|
||||
|
||||
@printedparts_bp.route('', methods=['GET'])
|
||||
@printedparts_bp.route('/items', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_printedparts():
|
||||
"""List printedparts assets, paginated."""
|
||||
def list_items():
|
||||
"""List printed items, paginated; search + low-stock filter."""
|
||||
page, per_page = get_pagination_params(request)
|
||||
|
||||
query = Printedparts.query
|
||||
query = PrintedItem.query
|
||||
if request.args.get('active', 'true').lower() != 'false':
|
||||
query = query.filter(PrintedItem.isactive == True)
|
||||
if search := request.args.get('search'):
|
||||
like = f'%{search}%'
|
||||
query = query.filter(or_(
|
||||
PrintedItem.itemcode.ilike(like),
|
||||
PrintedItem.itemname.ilike(like),
|
||||
PrintedItem.itemdescription.ilike(like),
|
||||
PrintedItem.binlocation.ilike(like),
|
||||
))
|
||||
if request.args.get('lowstock', '').lower() == 'true':
|
||||
query = query.filter(
|
||||
PrintedItem.quantityonhand <= PrintedItem.lowstockthreshold)
|
||||
query = query.order_by(PrintedItem.itemname)
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [item.to_dict() for item in items]
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
return paginated_response(
|
||||
[item.to_dict() for item in items], page, per_page, total)
|
||||
|
||||
|
||||
@printedparts_bp.route('/<int:assetid>', methods=['GET'])
|
||||
@printedparts_bp.route('/items/<int:item_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_printedparts(assetid: int):
|
||||
"""Get a single printedparts by assetid."""
|
||||
item = Printedparts.query.get(assetid)
|
||||
def get_item(item_id: int):
|
||||
"""Get one printed item with its recent transactions."""
|
||||
item = db.session.get(PrintedItem, item_id)
|
||||
if not item:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Printed item {item_id} not found',
|
||||
http_code=404)
|
||||
data = item.to_dict()
|
||||
recent = (item.transactions
|
||||
.order_by(db.desc('transactiondate'))
|
||||
.limit(25).all())
|
||||
data['recenttransactions'] = [t.to_dict() for t in recent]
|
||||
return success_response(data)
|
||||
|
||||
|
||||
# --- catalog mutations (stage 6 adds permission gates on top of jwt) --------
|
||||
|
||||
import glob
|
||||
import os
|
||||
|
||||
from flask import current_app
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.api import Setting
|
||||
|
||||
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
|
||||
IMAGE_URL_PREFIX = '/api/printedparts/image/'
|
||||
|
||||
EDITABLE_FIELDS = ('itemname', 'itemdescription', 'lowstockthreshold',
|
||||
'binlocation', 'printnotes')
|
||||
|
||||
|
||||
def _imagedir():
|
||||
return os.path.join(current_app.instance_path, 'printedpartsimages')
|
||||
|
||||
|
||||
def _mint_itemcode(item):
|
||||
"""Set itemcode from the configured prefix + the flushed row id."""
|
||||
prefix = Setting.get('printedparts_code_prefix') or '3DP'
|
||||
item.itemcode = f'{prefix}-{item.printeditemid:04d}'
|
||||
|
||||
|
||||
@printedparts_bp.route('/items', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.create')
|
||||
def create_item():
|
||||
"""Create a printed item; the itemcode is minted from the row id."""
|
||||
data = request.get_json() or {}
|
||||
itemname = (data.get('itemname') or '').strip()
|
||||
if not itemname:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'itemname is required')
|
||||
|
||||
threshold = data.get('lowstockthreshold')
|
||||
if threshold is None:
|
||||
threshold = int(Setting.get('printedparts_default_threshold') or 5)
|
||||
|
||||
item = PrintedItem(
|
||||
itemname=itemname,
|
||||
itemdescription=data.get('itemdescription'),
|
||||
lowstockthreshold=threshold,
|
||||
binlocation=data.get('binlocation'),
|
||||
printnotes=data.get('printnotes'),
|
||||
quantityonhand=0,
|
||||
)
|
||||
db.session.add(item)
|
||||
db.session.flush() # assigns printeditemid
|
||||
_mint_itemcode(item)
|
||||
db.session.commit()
|
||||
return success_response(item.to_dict(), message='Printed item created',
|
||||
http_code=201)
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.edit')
|
||||
def update_item(item_id: int):
|
||||
"""Update catalog fields. Quantity moves ONLY through the ledger."""
|
||||
item = db.session.get(PrintedItem, item_id)
|
||||
if not item:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Printed item {item_id} not found', http_code=404)
|
||||
data = request.get_json() or {}
|
||||
if 'quantityonhand' in data:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'Printedparts with assetid {assetid} not found',
|
||||
http_code=404,
|
||||
)
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'quantityonhand is ledger-managed; use restock or adjust')
|
||||
for field in EDITABLE_FIELDS:
|
||||
if field in data:
|
||||
setattr(item, field, data[field])
|
||||
db.session.commit()
|
||||
return success_response(item.to_dict(), message='Printed item updated')
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.delete')
|
||||
def delete_item(item_id: int):
|
||||
"""Soft-retire an item; its ledger history stays."""
|
||||
item = db.session.get(PrintedItem, item_id)
|
||||
if not item:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Printed item {item_id} not found', http_code=404)
|
||||
item.isactive = False
|
||||
db.session.commit()
|
||||
return success_response(message='Printed item retired')
|
||||
|
||||
|
||||
# --- item image: the models.py upload/serve/delete trio ---------------------
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/image', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.edit')
|
||||
def upload_item_image(item_id: int):
|
||||
"""Upload (or replace) the photo for an item (multipart file=<image>)."""
|
||||
item = db.session.get(PrintedItem, item_id)
|
||||
if not item:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Printed item {item_id} not found', http_code=404)
|
||||
upload = request.files.get('file')
|
||||
if not upload or not upload.filename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||
ext = os.path.splitext(upload.filename)[1].lower()
|
||||
if ext not in IMAGE_EXTENSIONS:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Unsupported image type {ext}')
|
||||
|
||||
imagedir = _imagedir()
|
||||
os.makedirs(imagedir, exist_ok=True)
|
||||
for old in glob.glob(os.path.join(
|
||||
imagedir, secure_filename(f'printeditem-{item_id}') + '.*')):
|
||||
os.remove(old)
|
||||
|
||||
filename = secure_filename(f'printeditem-{item_id}{ext}')
|
||||
upload.save(os.path.join(imagedir, filename))
|
||||
item.imageurl = f'{IMAGE_URL_PREFIX}{filename}'
|
||||
db.session.commit()
|
||||
return success_response(item.to_dict(), message='Item image uploaded')
|
||||
|
||||
|
||||
@printedparts_bp.route('/image/<path:filename>', methods=['GET'])
|
||||
def serve_item_image(filename):
|
||||
"""Serve an uploaded item image (public - kiosk and list read it)."""
|
||||
from flask import send_from_directory
|
||||
return send_from_directory(_imagedir(), filename)
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.delete')
|
||||
def delete_item_image(item_id: int):
|
||||
"""Clear an item image; delete the file only if this plugin owns it."""
|
||||
item = db.session.get(PrintedItem, item_id)
|
||||
if not item:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Printed item {item_id} not found', http_code=404)
|
||||
url = item.imageurl or ''
|
||||
if url.startswith(IMAGE_URL_PREFIX):
|
||||
filename = secure_filename(url[len(IMAGE_URL_PREFIX):])
|
||||
path = os.path.join(_imagedir(), filename)
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
item.imageurl = None
|
||||
db.session.commit()
|
||||
return success_response(item.to_dict(), message='Item image removed')
|
||||
|
||||
|
||||
# --- the ledger: restock and adjust (stage 6 gates with printedparts.restock)
|
||||
|
||||
from ..models import PrintedItemTransaction
|
||||
from ..services.badges import BadgeError, resolve_badge
|
||||
|
||||
|
||||
def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None):
|
||||
"""Append a ledger row and move the cached quantity in ONE commit.
|
||||
|
||||
The single-commit invariant is what keeps quantityonhand equal to the
|
||||
ledger sum; every write path must go through here.
|
||||
"""
|
||||
item.quantityonhand += quantitychange
|
||||
db.session.add(PrintedItemTransaction(
|
||||
printeditemid=item.printeditemid,
|
||||
transactiontype=transactiontype,
|
||||
quantitychange=quantitychange,
|
||||
employeesso=sso,
|
||||
employeename=name,
|
||||
reason=reason,
|
||||
))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.restock')
|
||||
def restock_item(item_id: int):
|
||||
"""Add freshly printed stock. Body: {quantity, badge}."""
|
||||
item = db.session.get(PrintedItem, item_id)
|
||||
if not item or not item.isactive:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Printed item {item_id} not found', http_code=404)
|
||||
data = request.get_json() or {}
|
||||
quantity = data.get('quantity')
|
||||
if not isinstance(quantity, int) or quantity < 1:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'quantity must be a positive integer')
|
||||
try:
|
||||
sso, name = resolve_badge(data.get('badge'))
|
||||
except BadgeError as badge_error:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
|
||||
http_code=422)
|
||||
_ledger_write(item, 'restock', quantity, sso, name)
|
||||
return success_response(item.to_dict(), message='Stock added')
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/adjust', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.restock')
|
||||
def adjust_item(item_id: int):
|
||||
"""Correct the count (damage, recount). Body: {quantitychange, reason, badge}."""
|
||||
item = db.session.get(PrintedItem, item_id)
|
||||
if not item or not item.isactive:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Printed item {item_id} not found', http_code=404)
|
||||
data = request.get_json() or {}
|
||||
quantitychange = data.get('quantitychange')
|
||||
if not isinstance(quantitychange, int) or quantitychange == 0:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'quantitychange must be a non-zero integer')
|
||||
reason = (data.get('reason') or '').strip()
|
||||
if not reason:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'reason is required for an adjustment')
|
||||
if item.quantityonhand + quantitychange < 0:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Adjustment would drive stock below zero '
|
||||
f'(on hand: {item.quantityonhand})')
|
||||
try:
|
||||
sso, name = resolve_badge(data.get('badge'))
|
||||
except BadgeError as badge_error:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
|
||||
http_code=422)
|
||||
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
|
||||
return success_response(item.to_dict(), message='Stock adjusted')
|
||||
|
||||
|
||||
# --- kiosk: UNauthenticated by decision record --------------------------------
|
||||
# The take endpoint is the product's first open WRITE. The proposal's decision
|
||||
# record sets the bar it must meet: decrement-only, badge-attributed, bounded,
|
||||
# physically rate-limited. It can reduce stock of an active item and nothing
|
||||
# else; identity comes from the badge resolved server-side, never the client.
|
||||
|
||||
@printedparts_bp.route('/kiosk/item/<itemcode>', methods=['GET'])
|
||||
def kiosk_item(itemcode):
|
||||
"""Item summary for a scanned bin barcode (open read for the kiosk)."""
|
||||
item = PrintedItem.query.filter(
|
||||
PrintedItem.itemcode == itemcode.strip(),
|
||||
PrintedItem.isactive == True).first()
|
||||
if not item:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'No part matches that barcode', http_code=404)
|
||||
return success_response(item.to_dict())
|
||||
|
||||
|
||||
@printedparts_bp.route('/kiosk/take', methods=['POST'])
|
||||
def kiosk_take():
|
||||
"""Take parts from a bin. Body: {itemcode, badge, quantity}."""
|
||||
data = request.get_json() or {}
|
||||
|
||||
item = PrintedItem.query.filter(
|
||||
PrintedItem.itemcode == (data.get('itemcode') or '').strip(),
|
||||
PrintedItem.isactive == True).first()
|
||||
if not item:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'No part matches that barcode', http_code=404)
|
||||
|
||||
quantity = data.get('quantity')
|
||||
if not isinstance(quantity, int) or quantity < 1:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Enter how many you are taking')
|
||||
if quantity > item.quantityonhand:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Only {item.quantityonhand} on hand - take fewer or see the '
|
||||
f'parts team')
|
||||
|
||||
try:
|
||||
sso, name = resolve_badge(data.get('badge'))
|
||||
except BadgeError as badge_error:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
|
||||
http_code=422)
|
||||
|
||||
_ledger_write(item, 'take', -quantity, sso, name)
|
||||
return success_response(item.to_dict(),
|
||||
message=f'Took {quantity}, {item.quantityonhand} left')
|
||||
|
||||
14
plugins/printedparts/migrations/env.py
Normal file
14
plugins/printedparts/migrations/env.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Alembic environment for the printedparts 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_printedparts (ADR-008). This plugin
|
||||
is NEW (post-cutover): its 0001 baseline really CREATES its tables.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'printedparts'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/printedparts/migrations/script.py.mako
Normal file
24
plugins/printedparts/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""printedparts plugin baseline (real create).
|
||||
|
||||
Post-ADR-008 plugin: this per-plugin chain is the sole authoritative creator
|
||||
of printeditems and printeditemtransactions - the core chain never knew them.
|
||||
Runs from `flask plugin install printedparts` (and `flask plugin upgrade-all`)
|
||||
after `flask db upgrade` builds the core schema.
|
||||
|
||||
Both tables are self-contained (the only FK is transactions -> items inside
|
||||
the plugin), so the shared create_plugin_tables helper would work here; the
|
||||
ops are written out explicitly anyway to match the measuringtools exemplar
|
||||
and keep the baseline reviewable.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'printedparts0001baseline'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'printeditems',
|
||||
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
||||
sa.Column('itemcode', sa.String(length=20), nullable=True),
|
||||
sa.Column('itemname', sa.String(length=120), nullable=False),
|
||||
sa.Column('itemdescription', sa.String(length=500), nullable=True),
|
||||
sa.Column('imageurl', sa.String(length=255), nullable=True),
|
||||
sa.Column('quantityonhand', sa.Integer(), nullable=False),
|
||||
sa.Column('lowstockthreshold', sa.Integer(), nullable=False),
|
||||
sa.Column('binlocation', sa.String(length=100), nullable=True),
|
||||
sa.Column('printnotes', sa.Text(), nullable=True),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('printeditemid'),
|
||||
sa.UniqueConstraint('itemcode'),
|
||||
)
|
||||
op.create_index('ix_printeditems_itemcode', 'printeditems', ['itemcode'])
|
||||
|
||||
op.create_table(
|
||||
'printeditemtransactions',
|
||||
sa.Column('transactionid', sa.Integer(), nullable=False),
|
||||
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
||||
sa.Column('transactiontype', sa.String(length=10), nullable=False),
|
||||
sa.Column('quantitychange', sa.Integer(), nullable=False),
|
||||
sa.Column('employeesso', sa.String(length=20), nullable=False),
|
||||
sa.Column('employeename', sa.String(length=120), nullable=True),
|
||||
sa.Column('reason', sa.String(length=255), nullable=True),
|
||||
sa.Column('transactiondate', sa.DateTime(), nullable=False),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['printeditemid'], ['printeditems.printeditemid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('transactionid'),
|
||||
)
|
||||
op.create_index('ix_printeditemtransactions_printeditemid',
|
||||
'printeditemtransactions', ['printeditemid'])
|
||||
op.create_index('ix_printeditemtransactions_employeesso',
|
||||
'printeditemtransactions', ['employeesso'])
|
||||
op.create_index('ix_printeditemtransactions_transactiondate',
|
||||
'printeditemtransactions', ['transactiondate'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('printeditemtransactions')
|
||||
op.drop_table('printeditems')
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Printedparts plugin models."""
|
||||
|
||||
from .printedparts import Printedparts
|
||||
from .printeditem import PrintedItem, PrintedItemTransaction, TRANSACTION_TYPES
|
||||
|
||||
__all__ = ['Printedparts']
|
||||
__all__ = ['PrintedItem', 'PrintedItemTransaction', 'TRANSACTION_TYPES']
|
||||
|
||||
95
plugins/printedparts/models/printeditem.py
Normal file
95
plugins/printedparts/models/printeditem.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Printedparts models.
|
||||
|
||||
PrintedItem is a KIND of 3D-printed part with a quantity on hand - a
|
||||
consumable, not an ADR-001 asset (which is one row per physical thing).
|
||||
PrintedItemTransaction is the ledger: every take, restock, and adjust as a
|
||||
signed quantity change attributed to a badge-resolved employee. The ledger is
|
||||
the source of truth; quantityonhand is a cache moved in the same commit as
|
||||
each ledger write, and the stock report reconciles the two.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
TRANSACTION_TYPES = ('take', 'restock', 'adjust')
|
||||
|
||||
|
||||
class PrintedItem(BaseModel):
|
||||
"""A printable part the engineers stock in bins."""
|
||||
|
||||
__tablename__ = 'printeditems'
|
||||
|
||||
printeditemid = db.Column(db.Integer, primary_key=True)
|
||||
itemcode = db.Column(db.String(20), unique=True, index=True,
|
||||
comment='Generated bin-label code, e.g. 3DP-0042')
|
||||
itemname = db.Column(db.String(120), nullable=False)
|
||||
itemdescription = db.Column(db.String(500))
|
||||
imageurl = db.Column(db.String(255))
|
||||
quantityonhand = db.Column(db.Integer, nullable=False, default=0)
|
||||
lowstockthreshold = db.Column(db.Integer, nullable=False, default=5)
|
||||
binlocation = db.Column(db.String(100))
|
||||
printnotes = db.Column(db.Text, comment='Material, print time, slicer file')
|
||||
|
||||
transactions = db.relationship(
|
||||
'PrintedItemTransaction', backref='printeditem',
|
||||
cascade='all, delete-orphan', passive_deletes=True, lazy='dynamic')
|
||||
|
||||
@property
|
||||
def islowstock(self):
|
||||
return self.quantityonhand <= self.lowstockthreshold
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'printeditemid': self.printeditemid,
|
||||
'itemcode': self.itemcode,
|
||||
'itemname': self.itemname,
|
||||
'itemdescription': self.itemdescription,
|
||||
'imageurl': self.imageurl,
|
||||
'quantityonhand': self.quantityonhand,
|
||||
'lowstockthreshold': self.lowstockthreshold,
|
||||
'islowstock': self.islowstock,
|
||||
'binlocation': self.binlocation,
|
||||
'printnotes': self.printnotes,
|
||||
'isactive': self.isactive,
|
||||
'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
||||
'modifieddate': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None,
|
||||
}
|
||||
|
||||
|
||||
class PrintedItemTransaction(BaseModel):
|
||||
"""One signed stock movement, always attributed to an employee."""
|
||||
|
||||
__tablename__ = 'printeditemtransactions'
|
||||
|
||||
transactionid = db.Column(db.Integer, primary_key=True)
|
||||
printeditemid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('printeditems.printeditemid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
transactiontype = db.Column(db.String(10), nullable=False,
|
||||
comment='take, restock, or adjust')
|
||||
quantitychange = db.Column(db.Integer, nullable=False,
|
||||
comment='Negative for take, signed for adjust')
|
||||
employeesso = db.Column(db.String(20), nullable=False, index=True)
|
||||
employeename = db.Column(db.String(120))
|
||||
reason = db.Column(db.String(255))
|
||||
transactiondate = db.Column(db.DateTime, nullable=False, default=_utcnow,
|
||||
index=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'transactionid': self.transactionid,
|
||||
'printeditemid': self.printeditemid,
|
||||
'transactiontype': self.transactiontype,
|
||||
'quantitychange': self.quantitychange,
|
||||
'employeesso': self.employeesso,
|
||||
'employeename': self.employeename,
|
||||
'reason': self.reason,
|
||||
'transactiondate': self.transactiondate.isoformat() + 'Z' if self.transactiondate else None,
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Printedparts model.
|
||||
|
||||
This is an Asset extension table keyed by assetid. The Asset row holds
|
||||
the platform fields (assetnumber, name, vendorid, locationid, etc.);
|
||||
this table holds the printedparts-specific fields. Replace the example fields
|
||||
below with your domain model.
|
||||
"""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class Printedparts(BaseModel):
|
||||
"""Printedparts domain entity, extending Asset by assetid."""
|
||||
|
||||
__tablename__ = 'printedparts'
|
||||
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
primary_key=True,
|
||||
)
|
||||
|
||||
# TODO: replace these example fields with your domain fields.
|
||||
examplefield = db.Column(db.String(255), nullable=True)
|
||||
|
||||
asset = db.relationship('Asset', backref=db.backref('printedparts', uselist=False))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'assetid': self.assetid,
|
||||
'examplefield': self.examplefield,
|
||||
}
|
||||
@@ -16,7 +16,7 @@ from flask import Flask, Blueprint
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, Setting
|
||||
|
||||
from .models import Printedparts
|
||||
from .models import PrintedItem, PrintedItemTransaction
|
||||
from .api import printedparts_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,11 +46,32 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
return printedparts_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [Printedparts]
|
||||
return [PrintedItem, PrintedItemTransaction]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f'Printedparts plugin initialized (v{self.meta.version})')
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""RBAC permissions this plugin owns (seeded on install/enable)."""
|
||||
return [
|
||||
('printedparts.view', 'View 3D printed parts', 'printedparts'),
|
||||
('printedparts.create', 'Create printed parts', 'printedparts'),
|
||||
('printedparts.edit', 'Edit printed parts', 'printedparts'),
|
||||
('printedparts.delete', 'Retire printed parts', 'printedparts'),
|
||||
('printedparts.restock', 'Restock and adjust stock counts',
|
||||
'printedparts'),
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[dict]:
|
||||
return [
|
||||
{
|
||||
'name': '3D Parts',
|
||||
'icon': 'box',
|
||||
'route': '/printedparts',
|
||||
'position': 46,
|
||||
},
|
||||
]
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
with app.app_context():
|
||||
self._seed_settings()
|
||||
|
||||
1
plugins/printedparts/services/__init__.py
Normal file
1
plugins/printedparts/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Printedparts plugin services."""
|
||||
69
plugins/printedparts/services/badges.py
Normal file
69
plugins/printedparts/services/badges.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Badge resolution for the printedparts plugin.
|
||||
|
||||
Same input contract as the USB plugin (deliberately copied, not imported -
|
||||
cross-plugin imports break the shopdb.api-only contract):
|
||||
|
||||
- all digits -> an SSO typed or scanned directly
|
||||
- 0<digits>BZ -> a physical badge wrapping a PayNo (keyboard-wedge
|
||||
scanners emit this shape)
|
||||
- anything else -> unresolvable
|
||||
|
||||
Names come from the employees plugin's self-hosted directory, looked up by
|
||||
SSO. The directory carries no PayNo column, so PayNo badges resolve only when
|
||||
the wrapped digits are themselves the SSO (true at sites whose badges encode
|
||||
the SSO); otherwise they fall to the unknown-badge policy.
|
||||
|
||||
Policy (Setting printedparts_unknown_badge): 'deny' (default) rejects a badge
|
||||
with no directory match; 'allow' records the SSO with an empty name.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from shopdb.api import Setting
|
||||
|
||||
_PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE)
|
||||
|
||||
|
||||
class BadgeError(ValueError):
|
||||
"""Raised when a badge cannot be accepted under the site policy."""
|
||||
|
||||
|
||||
def _directory_name(sso):
|
||||
"""Best-effort display name from the employees plugin directory."""
|
||||
try:
|
||||
from plugins.employees.models import DirectoryEmployee
|
||||
from shopdb.api import db
|
||||
if sso and str(sso).isdigit():
|
||||
employee = db.session.get(DirectoryEmployee, int(sso))
|
||||
if employee:
|
||||
return f'{employee.firstname} {employee.lastname}'.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def resolve_badge(badge):
|
||||
"""Return (sso, name) for a scanned badge, enforcing the site policy.
|
||||
|
||||
Raises BadgeError with a kiosk-displayable message when the badge shape is
|
||||
unrecognized or the policy denies an unmatched badge.
|
||||
"""
|
||||
badge = (badge or '').strip()
|
||||
if not badge:
|
||||
raise BadgeError('Scan or enter a badge')
|
||||
|
||||
if badge.isdigit():
|
||||
sso = badge
|
||||
else:
|
||||
match = _PAYNO_BADGE.match(badge)
|
||||
if not match:
|
||||
raise BadgeError('Unrecognized badge format')
|
||||
sso = match.group(1)
|
||||
|
||||
name = _directory_name(sso)
|
||||
if name is None:
|
||||
policy = (Setting.get('printedparts_unknown_badge') or 'deny').lower()
|
||||
if policy != 'allow':
|
||||
raise BadgeError('Badge not recognized - see the parts team')
|
||||
return sso, ''
|
||||
return sso, name
|
||||
@@ -53,6 +53,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'measuringtools': ('measuringtooltypes', 'measuringtools'),
|
||||
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
|
||||
'notifications': ('notificationtypes', 'notifications'),
|
||||
'printedparts': ('printeditems', 'printeditemtransactions'),
|
||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers'),
|
||||
'slides': ('tvslides',),
|
||||
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
||||
|
||||
@@ -54,6 +54,8 @@ EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
||||
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
|
||||
# usb drops the dead usbcheckouts.machineid column on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid'
|
||||
# printedparts is post-cutover: its 0001 really creates its tables.
|
||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0001baseline'
|
||||
# notifications indexes businessunitid on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0002buidx'
|
||||
|
||||
|
||||
168
tests/test_plugins/test_printedparts_ledger.py
Normal file
168
tests/test_plugins/test_printedparts_ledger.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Printedparts ledger + badge tests.
|
||||
|
||||
The invariants that make the plugin trustworthy: itemcode minting, the
|
||||
single-commit cache==ledger rule, the below-zero guard, quantity edits
|
||||
forced through the ledger, and the badge contract (SSO digits, PayNo
|
||||
wrap, unknown-badge policy).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.api import db
|
||||
from shopdb.core.models import Setting
|
||||
from plugins.printedparts.models import PrintedItem, PrintedItemTransaction
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def item(app, db):
|
||||
with app.app_context():
|
||||
row = PrintedItem(itemcode='3DP-9001', itemname='Test clip',
|
||||
quantityonhand=0, lowstockthreshold=5)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
yield row.printeditemid
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def directory_employee(app):
|
||||
with app.app_context():
|
||||
from plugins.employees.models import DirectoryEmployee
|
||||
if not db.session.get(DirectoryEmployee, 502000001):
|
||||
db.session.add(DirectoryEmployee(
|
||||
sso=502000001, firstname='Pat', lastname='Printer'))
|
||||
db.session.commit()
|
||||
return '502000001'
|
||||
|
||||
|
||||
def test_create_mints_itemcode(client, auth_headers):
|
||||
response = client.post('/api/printedparts/items', json={'itemname': 'Bracket'},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 201
|
||||
data = response.get_json()['data']
|
||||
assert data['itemcode'] == f"3DP-{data['printeditemid']:04d}"
|
||||
assert data['quantityonhand'] == 0
|
||||
|
||||
|
||||
def test_update_refuses_quantity(client, auth_headers, item):
|
||||
response = client.put(f'/api/printedparts/items/{item}',
|
||||
json={'quantityonhand': 50}, headers=auth_headers)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_restock_writes_ledger_and_cache(client, auth_headers, app, item,
|
||||
directory_employee):
|
||||
response = client.post(f'/api/printedparts/items/{item}/restock',
|
||||
json={'quantity': 10, 'badge': directory_employee},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['quantityonhand'] == 10
|
||||
with app.app_context():
|
||||
rows = PrintedItemTransaction.query.filter_by(printeditemid=item).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].transactiontype == 'restock'
|
||||
assert rows[0].quantitychange == 10
|
||||
assert rows[0].employeename == 'Pat Printer'
|
||||
cached = db.session.get(PrintedItem, item).quantityonhand
|
||||
assert cached == sum(r.quantitychange for r in rows)
|
||||
|
||||
|
||||
def test_payno_badge_shape_resolves(client, auth_headers, item,
|
||||
directory_employee):
|
||||
response = client.post(f'/api/printedparts/items/{item}/restock',
|
||||
json={'quantity': 1,
|
||||
'badge': f'0{directory_employee}BZ'},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_adjust_requires_reason_and_floors_at_zero(client, auth_headers, item,
|
||||
directory_employee):
|
||||
no_reason = client.post(f'/api/printedparts/items/{item}/adjust',
|
||||
json={'quantitychange': -1,
|
||||
'badge': directory_employee},
|
||||
headers=auth_headers)
|
||||
assert no_reason.status_code == 400
|
||||
|
||||
below_zero = client.post(f'/api/printedparts/items/{item}/adjust',
|
||||
json={'quantitychange': -1, 'reason': 'test',
|
||||
'badge': directory_employee},
|
||||
headers=auth_headers)
|
||||
assert below_zero.status_code == 400
|
||||
|
||||
|
||||
def test_unknown_badge_denied_then_allowed_by_policy(client, auth_headers, app,
|
||||
item):
|
||||
denied = client.post(f'/api/printedparts/items/{item}/restock',
|
||||
json={'quantity': 1, 'badge': '999999999'},
|
||||
headers=auth_headers)
|
||||
assert denied.status_code == 422
|
||||
|
||||
with app.app_context():
|
||||
Setting.set('printedparts_unknown_badge', 'allow',
|
||||
valuetype='string', category='printedparts')
|
||||
db.session.commit()
|
||||
try:
|
||||
allowed = client.post(f'/api/printedparts/items/{item}/restock',
|
||||
json={'quantity': 1, 'badge': '999999999'},
|
||||
headers=auth_headers)
|
||||
assert allowed.status_code == 200
|
||||
assert allowed.get_json()['data']['quantityonhand'] == 1
|
||||
finally:
|
||||
with app.app_context():
|
||||
Setting.set('printedparts_unknown_badge', 'deny',
|
||||
valuetype='string', category='printedparts')
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_anonymous_cannot_mutate(client, item):
|
||||
assert client.post('/api/printedparts/items',
|
||||
json={'itemname': 'X'}).status_code == 401
|
||||
assert client.post(f'/api/printedparts/items/{item}/restock',
|
||||
json={'quantity': 1, 'badge': '1'}).status_code == 401
|
||||
|
||||
|
||||
def test_member_without_permission_gets_403(client, member_headers, item):
|
||||
"""Authentication alone is not authorization: a role-less user is denied."""
|
||||
assert client.post('/api/printedparts/items', json={'itemname': 'X'},
|
||||
headers=member_headers).status_code == 403
|
||||
assert client.post(f'/api/printedparts/items/{item}/restock',
|
||||
json={'quantity': 1, 'badge': '1'},
|
||||
headers=member_headers).status_code == 403
|
||||
|
||||
|
||||
def test_kiosk_take_is_open_decrement_only(client, auth_headers, app, item,
|
||||
directory_employee):
|
||||
"""The kiosk endpoint needs no auth but only ever decrements stock."""
|
||||
itemcode = '3DP-9001'
|
||||
stocked = client.post(f'/api/printedparts/items/{item}/restock',
|
||||
json={'quantity': 5, 'badge': directory_employee},
|
||||
headers=auth_headers)
|
||||
assert stocked.status_code == 200
|
||||
|
||||
lookup = client.get(f'/api/printedparts/kiosk/item/{itemcode}')
|
||||
assert lookup.status_code == 200
|
||||
|
||||
take = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 2})
|
||||
assert take.status_code == 200, take.get_json()
|
||||
assert take.get_json()['data']['quantityonhand'] == 3
|
||||
|
||||
too_many = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 99})
|
||||
assert too_many.status_code == 400
|
||||
|
||||
unknown = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': itemcode, 'badge': '111111111', 'quantity': 1})
|
||||
assert unknown.status_code == 422
|
||||
|
||||
with app.app_context():
|
||||
rows = PrintedItemTransaction.query.filter_by(
|
||||
printeditemid=item, transactiontype='take').all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].quantitychange == -2
|
||||
assert rows[0].employeename == 'Pat Printer'
|
||||
cached = db.session.get(PrintedItem, item).quantityonhand
|
||||
ledgersum = sum(r.quantitychange for r in
|
||||
PrintedItemTransaction.query.filter_by(
|
||||
printeditemid=item).all())
|
||||
assert cached == ledgersum
|
||||
Reference in New Issue
Block a user