fixed warehouse article

This commit is contained in:
2025-12-17 17:00:53 +01:00
parent 37b0a22b33
commit 9680ff004e
3 changed files with 230 additions and 49 deletions

View File

@@ -56,12 +56,16 @@ class WarehouseArticleController extends TTCrud {
protected function beforeCreate() {
if (!in_array($this->user->id, [2, 5, 6, 145, 14]))
self::sendError("Sie haben keine Berechtigung, Artikel zu erstellen.");
$this->validateArticleNumber($_POST);
return true;
}
protected function beforeUpdate($postData): bool {
if (!in_array($this->user->id, [2, 5, 6, 145, 14]))
self::sendError("Sie haben keine Berechtigung, Artikel zu bearbeiten.");
$this->validateArticleNumber($postData, $postData['id'] ?? null);
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
@@ -84,6 +88,38 @@ class WarehouseArticleController extends TTCrud {
self::updateSellPrices($postData['id']);
}
/**
* Validate article number for duplicates and correct category prefix
*/
private function validateArticleNumber(array $postData, ?int $excludeId = null): void {
$articleNumber = $postData['articleNumber'] ?? '';
$categoryId = $postData['category_id'] ?? null;
if (empty($articleNumber)) {
self::sendError("Artikelnummer ist erforderlich.");
}
// Check for duplicate article number
$existingArticles = WarehouseArticleModel::getAll(['articleNumber' => $articleNumber]);
foreach ($existingArticles as $existing) {
if ($excludeId === null || $existing->id != $excludeId) {
self::sendError("Artikelnummer '{$articleNumber}' existiert bereits (Artikel ID: {$existing->id}).");
}
}
// Validate category prefix
if ($categoryId) {
$category = WarehouseCategory::get($categoryId);
if ($category && $category->articleNumberPrefix) {
$expectedPrefix = $category->articleNumberPrefix;
$articlePrefix = substr($articleNumber, 0, strlen($expectedPrefix));
if ($articlePrefix !== $expectedPrefix) {
self::sendError("Artikelnummer muss mit dem Kategorie-Prefix '{$expectedPrefix}' beginnen.");
}
}
}
}
public static function updateSellPrices(int $id): void { // Added return type hint
$a = WarehouseArticleModel::get($id);
if (!$a instanceof WarehouseArticleModel) throw new Exception("Invalid article type");

View File

@@ -13,6 +13,24 @@
background-color: #f8d7da !important;
}
/* Last Edited Row Highlighting */
.last-edited-row {
background-color: #fff3cd !important;
animation: highlight-fade 5s ease-out forwards;
}
@keyframes highlight-fade {
0% {
background-color: #fff3cd;
}
70% {
background-color: #fff3cd;
}
100% {
background-color: transparent;
}
}
/*
* Modal Layout
*/

View File

@@ -1,5 +1,13 @@
// Track last edited article for highlighting
window.TT_CONFIG.lastEditedArticleId = null;
window.TT_CONFIG.CRUD_CONFIG.customRowClass = (row) => {
if (row.isEndOfLife) return 'end-of-life';
const classes = [];
if (row.isEndOfLife) classes.push('end-of-life');
if (window.TT_CONFIG.lastEditedArticleId && row.id == window.TT_CONFIG.lastEditedArticleId) {
classes.push('last-edited-row');
}
return classes.join(' ');
}
async function handleApiResponse(responsePromise) {
@@ -14,15 +22,19 @@ async function handleApiResponse(responsePromise) {
}
Vue.component('warehouse-article-prices', {
props: {id: {type: Number, required: true}},
props: {
id: {type: Number, required: true},
cheapestPurchasePrice: {type: Number, default: null}
},
template: `
<div class="wa-prices-section">
<h5 class="wa-section-title"><i class="fas fa-tags mr-2"></i>Artikelpreise überschreiben</h5>
<div class="wa-prices-grid-dense">
<div v-for="(price, typeTitle) in articlePrices" :key="typeTitle" class="wa-price-item">
<div v-for="price in sortedPrices" :key="price.typeTitle" class="wa-price-item">
<div class="wa-price-header">
<i v-if="price.isRobot" class="fas fa-robot mr-1 text-muted" style="font-size: 0.75rem;"></i>
<strong style="font-size: 0.8rem;">{{ typeTitle }}</strong>
<strong style="font-size: 0.8rem;">{{ price.typeTitle }}</strong>
<span class="ml-1 text-muted" style="font-size: 0.75rem;">( {{ formatPrice(calculateCurrentPrice(price)) }} )</span>
<i v-if="price.pendingChanges" class="fas fa-exclamation-triangle text-warning ml-auto"
style="font-size: 0.75rem;" title="Nicht gespeichert"></i>
</div>
@@ -40,9 +52,6 @@ Vue.component('warehouse-article-prices', {
type="number"
sm no-form-group/>
<div class="wa-price-actions">
<button class="btn btn-sm btn-primary" @click="savePrice(price)" title="Speichern">
<i class="fas fa-save"></i>
</button>
<button class="btn btn-sm btn-danger" @click="deletePrice(price)" :disabled="price.isRobot" title="Löschen">
<i class="fas fa-trash"></i>
</button>
@@ -52,11 +61,51 @@ Vue.component('warehouse-article-prices', {
</div>
</div>
`,
data: () => ({window, articlePrices: {}}),
data: () => ({window, articlePrices: {}, priceTypes: []}),
computed: {
sortedPrices() {
// Sort: Verkauf first, Partner second, rest alphabetically
const priceOrder = {'Verkauf': 1, 'Partner': 2, 'Energie Steiermark': 3};
return Object.entries(this.articlePrices)
.map(([typeTitle, price]) => ({...price, typeTitle}))
.sort((a, b) => {
const orderA = priceOrder[a.typeTitle] || 99;
const orderB = priceOrder[b.typeTitle] || 99;
if (orderA !== orderB) return orderA - orderB;
return a.typeTitle.localeCompare(b.typeTitle);
});
}
},
async mounted() {
await this.fetchArticlePrices();
},
methods: {
formatPrice(price) {
if (price === null || price === undefined || isNaN(price)) return '-- €';
return price.toFixed(2).replace('.', ',') + ' €';
},
calculateCurrentPrice(price) {
const basePrice = this.cheapestPurchasePrice;
if (basePrice === null || basePrice === undefined) return null;
// If custom override price is set, use it
if (price.priceOverride !== null && price.priceOverride !== undefined && price.priceOverride !== '') {
return parseFloat(price.priceOverride);
}
// If custom multiplier is set, use it
if (price.priceMultiplier !== null && price.priceMultiplier !== undefined && price.priceMultiplier !== '') {
return basePrice * parseFloat(price.priceMultiplier);
}
// Fall back to default factor from price type
const priceType = this.priceTypes.find(pt => pt.id === price.articlePriceTypeId);
if (priceType && priceType.defaultPriceFactor) {
return basePrice * priceType.defaultPriceFactor;
}
return null;
},
handleFactorInput(price) {
if (price.priceMultiplier) price.priceOverride = null;
price.pendingChanges = true;
@@ -70,15 +119,16 @@ Vue.component('warehouse-article-prices', {
axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/get`, {filters: {articleId: this.id}}),
axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePriceType/get`)
]);
this.priceTypes = typesRes.data.rows || [];
const prices = {};
typesRes.data.rows.forEach(type => prices[type.title] = {
this.priceTypes.forEach(type => prices[type.title] = {
isRobot: true,
articlePriceTypeId: type.id,
priceMultiplier: type.defaultPriceFactor,
priceOverride: null
});
pricesRes.data.rows.forEach(pData => {
const type = typesRes.data.rows.find(t => t.id === pData.articlePriceTypeId);
const type = this.priceTypes.find(t => t.id === pData.articlePriceTypeId);
if (!type) return;
prices[type.title] = {
id: pData.id,
@@ -91,18 +141,25 @@ Vue.component('warehouse-article-prices', {
});
this.articlePrices = prices;
},
async savePrice(price) {
const payload = {
articleId: this.id,
articlePriceTypeId: price.articlePriceTypeId,
priceMultiplier: price.priceMultiplier ? parseFloat(price.priceMultiplier.toString().replace(',', '.')) : null,
priceOverride: price.priceOverride ? parseFloat(price.priceOverride.toString().replace(',', '.')) : null
};
const endpoint = price.isRobot ? 'create' : 'update';
const data = price.isRobot ? payload : {id: price.id, ...payload};
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/${endpoint}`, data));
async savePrices() {
// Save all prices with pending changes
const pendingPrices = this.sortedPrices.filter(p => p.pendingChanges);
for (const price of pendingPrices) {
const payload = {
articleId: this.id,
articlePriceTypeId: price.articlePriceTypeId,
priceMultiplier: price.priceMultiplier ? parseFloat(price.priceMultiplier.toString().replace(',', '.')) : null,
priceOverride: price.priceOverride ? parseFloat(price.priceOverride.toString().replace(',', '.')) : null
};
const endpoint = price.isRobot ? 'create' : 'update';
const data = price.isRobot ? payload : {id: price.id, ...payload};
await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/${endpoint}`, data);
}
await this.fetchArticlePrices();
},
hasPendingChanges() {
return this.sortedPrices.some(p => p.pendingChanges);
},
async deletePrice(price) {
const payload = {id: price.id, articleId: this.id, articlePriceTypeId: price.articlePriceTypeId}
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/delete`, payload));
@@ -243,9 +300,6 @@ Vue.component('warehouse-article-distributor', {
sm no-form-group/>
</div>
<div class="wa-distributor-actions">
<button class="btn btn-sm btn-primary" @click="saveDistributor(distributor)" title="Speichern">
<i class="fas fa-save"></i>
</button>
<button class="btn btn-sm btn-danger" @click="deleteDistributor(distributor.id)" :disabled="!distributor.id" title="Löschen">
<i class="fas fa-trash"></i>
</button>
@@ -292,10 +346,18 @@ Vue.component('warehouse-article-distributor', {
pendingChanges: true
});
},
async saveDistributor(distributor) {
delete distributor.pendingChanges;
distributor.purchasePrice = distributor.purchasePrice ? parseFloat(distributor.purchasePrice.toString().replace(',', '.')) : null;
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticleDistributor/${distributor.id ? 'update' : 'create'}`, distributor));
hasPendingChanges() {
return this.articleDistributors.some(d => d.pendingChanges || !d.id);
},
async saveDistributors() {
// Save all distributors with pending changes or newly added ones
const pendingDistributors = this.articleDistributors.filter(d => d.pendingChanges || !d.id);
for (const distributor of pendingDistributors) {
const data = {...distributor};
delete data.pendingChanges;
data.purchasePrice = data.purchasePrice ? parseFloat(data.purchasePrice.toString().replace(',', '.')) : null;
await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticleDistributor/${data.id ? 'update' : 'create'}`, data);
}
await this.fetchArticleDistributors();
},
async deleteDistributor(distributorId) {
@@ -385,10 +447,10 @@ Vue.component('warehouse-article-modal', {
</div>
<!-- Prices Section -->
<warehouse-article-prices v-if="isEditMode" :id="Number(id)"/>
<warehouse-article-prices v-if="isEditMode" ref="pricesComponent" :id="Number(id)" :cheapest-purchase-price="cheapestPurchasePrice"/>
<!-- Distributors Section -->
<warehouse-article-distributor v-if="isEditMode" :id="Number(id)"/>
<warehouse-article-distributor v-if="isEditMode" ref="distributorComponent" :id="Number(id)"/>
<!-- Additional Attributes -->
<div class="wa-section" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
@@ -450,11 +512,16 @@ Vue.component('warehouse-article-modal', {
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="close">Abbrechen</button>
<button type="button" class="btn btn-primary" @click="save" :disabled="saving || !isValid">
<button type="button" class="btn btn-outline-primary" @click="save(false)" :disabled="saving || !isValid">
<i v-if="saving" class="fas fa-spinner fa-spin mr-1"></i>
<i v-else class="fas fa-save mr-1"></i>
Speichern
</button>
<button type="button" class="btn btn-primary" @click="save(true)" :disabled="saving || !isValid">
<i v-if="saving" class="fas fa-spinner fa-spin mr-1"></i>
<i v-else class="fas fa-save mr-1"></i>
Speichern und Schließen
</button>
</div>
</div>
</div>
@@ -463,6 +530,9 @@ Vue.component('warehouse-article-modal', {
data: () => ({
loading: false,
saving: false,
originalCategoryId: null,
cheapestPurchasePrice: null,
originalFormData: null,
formData: {
title: '',
description: '',
@@ -548,6 +618,12 @@ Vue.component('warehouse-article-modal', {
isSbidiShop: !!data.isSbidiShop,
isSbidiShopHide: !!data.isSbidiShopHide
};
// Store original category to detect changes
this.originalCategoryId = data.category_id;
// Store cheapest purchase price for price calculations
this.cheapestPurchasePrice = data.cheapestPurchasePrice || null;
// Store original form data to detect changes
this.originalFormData = JSON.stringify(this.formData);
}
} catch (e) {
window.notify('error', 'Fehler beim Laden');
@@ -574,7 +650,10 @@ Vue.component('warehouse-article-modal', {
};
},
async onCategoryChange(categoryId) {
if (!categoryId || this.isEditMode) return;
if (!categoryId) return;
// In edit mode, only regenerate if category actually changed from original
if (this.isEditMode && categoryId == this.originalCategoryId) return;
try {
const res = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseArticle/getNextArticleNumber`, {
params: { categoryId: categoryId }
@@ -586,29 +665,77 @@ Vue.component('warehouse-article-modal', {
console.error('Failed to get next article number:', e);
}
},
async save() {
async save(closeAfterSave = true) {
if (!this.isValid) return;
this.saving = true;
try {
const endpoint = this.isEditMode ? 'update' : 'create';
const payload = {
...this.formData,
isSerialDocumentation: this.formData.isSerialDocumentation ? 1 : 0,
isEndOfLife: this.formData.isEndOfLife ? 1 : 0,
isEShop: this.formData.isEShop ? 1 : 0,
isEShopHide: this.formData.isEShopHide ? 1 : 0,
isSbidiShop: this.formData.isSbidiShop ? 1 : 0,
isSbidiShopHide: this.formData.isSbidiShopHide ? 1 : 0
};
if (this.isEditMode) payload.id = Number(this.id);
let savedPrices = false;
let savedDistributors = false;
let savedArticle = false;
const res = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseArticle/${endpoint}`, payload);
if (res.data.success) {
window.notify('success', res.data.message || 'Gespeichert');
if (!this.isEditMode && window.TT_CONFIG.CRUD_CONFIG.reopenOnCreate) this.$emit('reopen', res.data.id);
else this.$emit('close');
// Save prices and distributors first (only in edit mode)
if (this.isEditMode) {
if (this.$refs.pricesComponent && this.$refs.pricesComponent.hasPendingChanges()) {
await this.$refs.pricesComponent.savePrices();
savedPrices = true;
}
if (this.$refs.distributorComponent && this.$refs.distributorComponent.hasPendingChanges()) {
await this.$refs.distributorComponent.saveDistributors();
savedDistributors = true;
}
}
// Check if main article data actually changed
const currentFormData = JSON.stringify(this.formData);
const articleDataChanged = !this.isEditMode || this.originalFormData !== currentFormData;
if (articleDataChanged) {
const endpoint = this.isEditMode ? 'update' : 'create';
const payload = {
...this.formData,
isSerialDocumentation: this.formData.isSerialDocumentation ? 1 : 0,
isEndOfLife: this.formData.isEndOfLife ? 1 : 0,
isEShop: this.formData.isEShop ? 1 : 0,
isEShopHide: this.formData.isEShopHide ? 1 : 0,
isSbidiShop: this.formData.isSbidiShop ? 1 : 0,
isSbidiShopHide: this.formData.isSbidiShopHide ? 1 : 0
};
if (this.isEditMode) payload.id = Number(this.id);
const res = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseArticle/${endpoint}`, payload);
if (res.data.success) {
savedArticle = true;
// Track last edited article for row highlighting
window.TT_CONFIG.lastEditedArticleId = this.isEditMode ? Number(this.id) : res.data.id;
if (!this.isEditMode) {
// For new articles, reopen in edit mode
window.notify('success', res.data.message || 'Gespeichert');
this.$emit('reopen', res.data.id);
return;
}
} else {
window.notify('error', res.data.message || 'Fehler beim Speichern');
return;
}
}
// Show success message if anything was saved
if (savedArticle || savedPrices || savedDistributors) {
window.TT_CONFIG.lastEditedArticleId = Number(this.id);
window.notify('success', 'Gespeichert');
} else {
window.notify('error', res.data.message || 'Fehler beim Speichern');
window.notify('info', 'Keine Änderungen');
}
if (closeAfterSave) {
// Close modal if requested
this.$emit('close');
} else {
// Stay open - reload data to refresh prices/distributors
await this.loadArticle();
if (this.$refs.pricesComponent) await this.$refs.pricesComponent.fetchArticlePrices();
if (this.$refs.distributorComponent) await this.$refs.distributorComponent.fetchArticleDistributors();
}
} catch (e) {
window.notify('error', 'Fehler beim Speichern');