fixed warehouse article
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user