Autosave: 20260206-055506
This commit is contained in:
parent
73951729f9
commit
54a353ee6a
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-06 05:45
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0025_sale_subtotal_sale_vat_amount'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PurchaseOrder',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('lpo_number', models.CharField(blank=True, max_length=50, verbose_name='LPO Number')),
|
||||
('total_amount', models.DecimalField(decimal_places=3, max_digits=15, verbose_name='Total Amount')),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('sent', 'Sent'), ('converted', 'Converted to Purchase'), ('cancelled', 'Cancelled')], default='draft', max_length=20, verbose_name='Status')),
|
||||
('issue_date', models.DateField(default=django.utils.timezone.now, verbose_name='Issue Date')),
|
||||
('expected_date', models.DateField(blank=True, null=True, verbose_name='Expected Date')),
|
||||
('notes', models.TextField(blank=True, verbose_name='Notes')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_orders', to=settings.AUTH_USER_MODEL)),
|
||||
('supplier', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='purchase_orders', to='core.supplier')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='purchase_order',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='converted_purchase', to='core.purchaseorder'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PurchaseOrderItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='Quantity')),
|
||||
('cost_price', models.DecimalField(decimal_places=3, max_digits=12, verbose_name='Cost Price')),
|
||||
('line_total', models.DecimalField(decimal_places=3, max_digits=15, verbose_name='Line Total')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.product')),
|
||||
('purchase_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.purchaseorder')),
|
||||
],
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@ -232,6 +232,37 @@ class QuotationItem(models.Model):
|
||||
def __str__(self):
|
||||
return f"{self.product.name_en} x {self.quantity}"
|
||||
|
||||
class PurchaseOrder(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
('draft', _('Draft')),
|
||||
('sent', _('Sent')),
|
||||
('converted', _('Converted to Purchase')),
|
||||
('cancelled', _('Cancelled')),
|
||||
]
|
||||
|
||||
supplier = models.ForeignKey(Supplier, on_delete=models.SET_NULL, null=True, related_name="purchase_orders")
|
||||
lpo_number = models.CharField(_("LPO Number"), max_length=50, blank=True)
|
||||
total_amount = models.DecimalField(_("Total Amount"), max_digits=15, decimal_places=3)
|
||||
status = models.CharField(_("Status"), max_length=20, choices=STATUS_CHOICES, default='draft')
|
||||
issue_date = models.DateField(_("Issue Date"), default=timezone.now)
|
||||
expected_date = models.DateField(_("Expected Date"), null=True, blank=True)
|
||||
notes = models.TextField(_("Notes"), blank=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="purchase_orders")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"LPO #{self.id} - {self.supplier.name if self.supplier else 'N/A'}"
|
||||
|
||||
class PurchaseOrderItem(models.Model):
|
||||
purchase_order = models.ForeignKey(PurchaseOrder, on_delete=models.CASCADE, related_name="items")
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
||||
quantity = models.DecimalField(_("Quantity"), max_digits=15, decimal_places=2)
|
||||
cost_price = models.DecimalField(_("Cost Price"), max_digits=12, decimal_places=3)
|
||||
line_total = models.DecimalField(_("Line Total"), max_digits=15, decimal_places=3)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name_en} x {self.quantity}"
|
||||
|
||||
class Purchase(models.Model):
|
||||
PAYMENT_TYPE_CHOICES = [
|
||||
('cash', _('Cash')),
|
||||
@ -245,6 +276,7 @@ class Purchase(models.Model):
|
||||
]
|
||||
|
||||
supplier = models.ForeignKey(Supplier, on_delete=models.SET_NULL, null=True, related_name="purchases")
|
||||
purchase_order = models.ForeignKey(PurchaseOrder, on_delete=models.SET_NULL, null=True, blank=True, related_name="converted_purchase")
|
||||
invoice_number = models.CharField(_("Invoice Number"), max_length=50, blank=True)
|
||||
total_amount = models.DecimalField(_("Total Amount"), max_digits=15, decimal_places=3)
|
||||
paid_amount = models.DecimalField(_("Paid Amount"), max_digits=15, decimal_places=3, default=0)
|
||||
|
||||
@ -93,6 +93,11 @@
|
||||
<i class="bi bi-chevron-down chevron"></i>
|
||||
</a>
|
||||
<ul class="collapse list-unstyled sub-menu {% if url_name == 'purchases' or url_name == 'purchase_create' or url_name == 'purchase_detail' or url_name == 'supplier_payments' or 'purchases/returns' in path %}show{% endif %}" id="purchasesSubmenu">
|
||||
<li>
|
||||
<a href="{% url 'lpo_list' %}" class="{% if url_name == 'lpo_list' or url_name == 'lpo_create' or url_name == 'lpo_detail' %}active{% endif %}">
|
||||
<i class="bi bi-file-earmark-text"></i> {% trans "Purchase Orders (LPO)" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'purchase_create' %}" class="{% if url_name == 'purchase_create' %}active{% endif %}">
|
||||
<i class="bi bi-plus-circle"></i> {% trans "New Purchase" %}
|
||||
|
||||
261
core/templates/core/lpo_create.html
Normal file
261
core/templates/core/lpo_create.html
Normal file
@ -0,0 +1,261 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n l10n %}
|
||||
|
||||
{% block title %}{% trans "New LPO" %} | {{ site_settings.business_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4" id="lpoApp">
|
||||
<div class="row">
|
||||
<!-- Main Form -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm rounded-4 mb-4">
|
||||
<div class="card-header bg-white border-0 pt-4 px-4">
|
||||
<h5 class="fw-bold mb-0"><i class="bi bi-file-earmark-plus me-2 text-primary"></i>{% trans "Create Purchase Order (LPO)" %}</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<!-- Supplier & Info -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold">{% trans "Supplier" %}</label>
|
||||
<select class="form-select rounded-3 shadow-none border-secondary-subtle" v-model="supplierId">
|
||||
<option value="">{% trans "Select Supplier" %}</option>
|
||||
{% for supplier in suppliers %}
|
||||
<option value="{{ supplier.id }}">{{ supplier.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold">{% trans "LPO #" %}</label>
|
||||
<input type="text" class="form-control rounded-3 shadow-none border-secondary-subtle" v-model="lpoNumber" placeholder="{% trans 'Auto-generated if empty' %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-bold">{% trans "Add Items to Order" %}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0 border-secondary-subtle"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control rounded-3 border-start-0 border-secondary-subtle shadow-none" placeholder="{% trans 'Search by Name or SKU...' %}" v-model="searchQuery" @input="filterProducts">
|
||||
</div>
|
||||
|
||||
<div class="position-relative">
|
||||
<div class="list-group position-absolute w-100 shadow rounded-3 mt-1" style="z-index: 1000;" v-if="filteredProducts.length > 0">
|
||||
<button v-for="product in filteredProducts" :key="product.id" class="list-group-item list-group-item-action border-0 py-3" @click="addItem(product)">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<span class="fw-bold">[[ product.name_en ]]</span> / [[ product.name_ar ]]
|
||||
<div class="text-muted small">SKU: [[ product.sku ]] | Stock: [[ product.stock ]]</div>
|
||||
</div>
|
||||
<div class="text-primary fw-bold">[[ currencySymbol ]][[ product.cost_price ]]</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead class="bg-light-subtle">
|
||||
<tr class="small text-uppercase text-muted fw-bold">
|
||||
<th style="width: 40%;">{% trans "Product" %}</th>
|
||||
<th class="text-center">{% trans "Cost Price" %}</th>
|
||||
<th class="text-center" style="width: 15%;">{% trans "Quantity" %}</th>
|
||||
<th class="text-end">{% trans "Total" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in cart" :key="index">
|
||||
<td>
|
||||
<div class="fw-bold">[[ item.name_en ]]</div>
|
||||
<div class="text-muted small">[[ item.sku ]]</div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="0.001" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" v-model="item.price" @input="calculateTotal">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" step="0.01" v-model="item.quantity" @input="calculateTotal">
|
||||
</td>
|
||||
<td class="text-end fw-bold">[[ currencySymbol ]][[ (item.price * item.quantity).toFixed(decimalPlaces) ]]</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="cart.length === 0">
|
||||
<td colspan="5" class="text-center py-5 text-muted">
|
||||
{% trans "No items added yet." %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Summary -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm rounded-4 sticky-top" style="top: 20px;">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-4">{% trans "LPO Summary" %}</h5>
|
||||
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">{% trans "Total Items" %}</span>
|
||||
<span class="fw-bold">[[ cart.length ]]</span>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="d-flex justify-content-between mb-4">
|
||||
<h4 class="fw-bold mb-0">{% trans "Grand Total" %}</h4>
|
||||
<h4 class="fw-bold text-primary mb-0">[[ currencySymbol ]][[ subtotal.toFixed(decimalPlaces) ]]</h4>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">{% trans "Issue Date" %}</label>
|
||||
<input type="date" class="form-control rounded-3" v-model="issueDate">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">{% trans "Expected Date" %}</label>
|
||||
<input type="date" class="form-control rounded-3" v-model="expectedDate">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-bold">{% trans "Notes" %}</label>
|
||||
<textarea class="form-control rounded-3" rows="2" v-model="notes"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button class="btn btn-primary rounded-3 py-3 fw-bold shadow-sm" :disabled="isProcessing || cart.length === 0 || !supplierId" @click="saveOrder">
|
||||
<span v-if="isProcessing" class="spinner-border spinner-border-sm me-2"></span>
|
||||
<i class="bi bi-check2-circle me-2" v-else></i>
|
||||
{% trans "Save LPO" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vue.js 3 -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
{% localize off %}
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
createApp({
|
||||
delimiters: ['[[', ']]'],
|
||||
data() {
|
||||
return {
|
||||
products: [
|
||||
{% for p in products %}
|
||||
{
|
||||
id: {{ p.id|default:0 }},
|
||||
name_en: "{{ p.name_en|escapejs }}",
|
||||
name_ar: "{{ p.name_ar|escapejs }}",
|
||||
sku: "{{ p.sku|escapejs }}",
|
||||
cost_price: {{ p.cost_price|default:0 }},
|
||||
stock: {{ p.stock_quantity|default:0 }}
|
||||
},
|
||||
{% endfor %}
|
||||
],
|
||||
searchQuery: '',
|
||||
filteredProducts: [],
|
||||
cart: [],
|
||||
supplierId: '',
|
||||
lpoNumber: '',
|
||||
issueDate: new Date().toISOString().split('T')[0],
|
||||
expectedDate: '',
|
||||
notes: '',
|
||||
currencySymbol: '{{ site_settings.currency_symbol|escapejs }}',
|
||||
decimalPlaces: {{ decimal_places|default:3 }},
|
||||
isProcessing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
subtotal() {
|
||||
return this.cart.reduce((total, item) => total + (item.price * item.quantity), 0);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
filterProducts() {
|
||||
if (this.searchQuery.length > 1) {
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
this.filteredProducts = this.products.filter(p =>
|
||||
p.name_en.toLowerCase().includes(query) ||
|
||||
p.sku.toLowerCase().includes(query) ||
|
||||
p.name_ar.includes(query)
|
||||
).slice(0, 5);
|
||||
} else {
|
||||
this.filteredProducts = [];
|
||||
}
|
||||
},
|
||||
addItem(product) {
|
||||
const existing = this.cart.find(item => item.id === product.id);
|
||||
if (existing) {
|
||||
existing.quantity++;
|
||||
} else {
|
||||
this.cart.push({
|
||||
id: product.id,
|
||||
name_en: product.name_en,
|
||||
sku: product.sku,
|
||||
price: product.cost_price,
|
||||
quantity: 1
|
||||
});
|
||||
}
|
||||
this.searchQuery = '';
|
||||
this.filteredProducts = [];
|
||||
},
|
||||
removeItem(index) {
|
||||
this.cart.splice(index, 1);
|
||||
},
|
||||
saveOrder() {
|
||||
if (this.isProcessing) return;
|
||||
this.isProcessing = true;
|
||||
|
||||
const payload = {
|
||||
supplier_id: this.supplierId,
|
||||
lpo_number: this.lpoNumber,
|
||||
items: this.cart.map(item => ({
|
||||
id: item.id,
|
||||
quantity: item.quantity,
|
||||
price: item.price,
|
||||
line_total: item.price * item.quantity
|
||||
})),
|
||||
total_amount: this.subtotal,
|
||||
issue_date: this.issueDate,
|
||||
expected_date: this.expectedDate,
|
||||
notes: this.notes
|
||||
};
|
||||
|
||||
fetch("{% url 'create_lpo_api' %}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.location.href = "{% url 'lpo_list' %}";
|
||||
} else {
|
||||
alert("Error: " + data.error);
|
||||
this.isProcessing = false;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
alert("An unexpected error occurred.");
|
||||
this.isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}).mount('#lpoApp');
|
||||
</script>
|
||||
{% endlocalize %}
|
||||
{% endblock %}
|
||||
217
core/templates/core/lpo_detail.html
Normal file
217
core/templates/core/lpo_detail.html
Normal file
@ -0,0 +1,217 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "LPO Detail" %} #{{ order.id }} | {{ site_settings.business_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<!-- Action Bar -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 d-print-none">
|
||||
<a href="{% url 'lpo_list' %}" class="btn btn-light rounded-3">
|
||||
<i class="bi bi-arrow-left me-2"></i>{% trans "Back to List" %} / العودة للقائمة
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
{% if order.status != 'converted' %}
|
||||
<form action="{% url 'convert_lpo_to_purchase' order.id %}" method="POST" onsubmit="return confirm('{% trans 'Convert this LPO to a Purchase Invoice? This will update stock.' %}');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-success rounded-3 px-4 shadow-sm">
|
||||
<i class="bi bi-check2-circle me-2"></i>{% trans "Convert to Purchase" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button onclick="downloadPDF()" class="btn btn-outline-primary rounded-3 px-4">
|
||||
<i class="bi bi-file-earmark-pdf me-2"></i>{% trans "Download PDF" %} / تحميل PDF
|
||||
</button>
|
||||
<button onclick="window.print()" class="btn btn-primary rounded-3 px-4 shadow-sm">
|
||||
<i class="bi bi-printer me-2"></i>{% trans "Print Order" %} / طباعة الأمر
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Content -->
|
||||
<div id="order-card" class="card border-0 shadow-sm rounded-4 overflow-hidden mx-auto" style="max-width: 800px;">
|
||||
<div class="card-body p-0">
|
||||
<!-- Header Section -->
|
||||
<div class="p-5 bg-white">
|
||||
<div class="row mb-5">
|
||||
<div class="col-sm-6">
|
||||
{% if settings.logo %}
|
||||
<img src="{{ settings.logo.url }}" alt="Logo" style="max-height: 80px;" class="mb-4">
|
||||
{% else %}
|
||||
<h3 class="fw-bold text-primary mb-4">{{ settings.business_name }}</h3>
|
||||
{% endif %}
|
||||
<div class="text-muted small">
|
||||
<p class="mb-1"><i class="bi bi-geo-alt me-2"></i>{{ settings.address }}</p>
|
||||
<p class="mb-1"><i class="bi bi-telephone me-2"></i>{{ settings.phone }}</p>
|
||||
<p class="mb-1"><i class="bi bi-envelope me-2"></i>{{ settings.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 text-sm-end">
|
||||
<h1 class="fw-bold text-uppercase text-muted opacity-50 mb-4">{% trans "Purchase Order" %} / أمر شراء</h1>
|
||||
<div class="mb-4">
|
||||
<div class="fw-bold text-dark text-uppercase small">{% trans "LPO Number" %} / رقم الأمر</div>
|
||||
<div class="h5">{{ order.lpo_number|default:order.id }}</div>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-4">
|
||||
<div class="small text-muted fw-bold text-uppercase">{% trans "Issue Date" %} / تاريخ الإصدار</div>
|
||||
<div>{{ order.issue_date|date:"Y-m-d" }}</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="small text-muted fw-bold text-uppercase">{% trans "Issued By" %} / صادرة عن</div>
|
||||
<div>{{ order.created_by.username|default:"System" }}</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="small text-muted fw-bold text-uppercase">{% trans "Expected Date" %} / التاريخ المتوقع</div>
|
||||
<div>{{ order.expected_date|date:"Y-m-d"|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-5">
|
||||
<div class="col-sm-6">
|
||||
<div class="small text-muted fw-bold mb-3 text-uppercase tracking-wider">{% trans "Supplier Information" %} / معلومات المورد</div>
|
||||
<div class="h5 fw-bold mb-1">{{ order.supplier.name }}</div>
|
||||
{% if order.supplier.phone %}
|
||||
<div class="text-muted small"><i class="bi bi-telephone me-2"></i>{{ order.supplier.phone }}</div>
|
||||
{% endif %}
|
||||
{% if order.supplier.contact_person %}
|
||||
<div class="text-muted small"><i class="bi bi-person me-2"></i>{{ order.supplier.contact_person }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-sm-6 text-sm-end">
|
||||
<div class="small text-muted fw-bold mb-3 text-uppercase tracking-wider">{% trans "Order Status" %} / حالة الطلب</div>
|
||||
<div>
|
||||
{% if order.status == 'converted' %}
|
||||
<span class="h5 badge bg-success text-white rounded-pill px-4">{% trans "Converted" %} / تم التحويل</span>
|
||||
{% elif order.status == 'sent' %}
|
||||
<span class="h5 badge bg-info text-white rounded-pill px-4">{% trans "Sent" %} / مرسل</span>
|
||||
{% else %}
|
||||
<span class="h5 badge bg-secondary text-white rounded-pill px-4">{% trans "Draft" %} / مسودة</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Section -->
|
||||
<div class="table-responsive mb-5">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="py-3 ps-4 border-0">
|
||||
<div class="small text-muted">{% trans "Item Description" %}</div>
|
||||
<div class="small">وصف العنصر</div>
|
||||
</th>
|
||||
<th class="py-3 text-center border-0">
|
||||
<div class="small text-muted">{% trans "Cost Price" %}</div>
|
||||
<div class="small">سعر التكلفة</div>
|
||||
</th>
|
||||
<th class="py-3 text-center border-0">
|
||||
<div class="small text-muted">{% trans "Quantity" %}</div>
|
||||
<div class="small">الكمية</div>
|
||||
</th>
|
||||
<th class="py-3 text-end pe-4 border-0">
|
||||
<div class="small text-muted">{% trans "Total" %}</div>
|
||||
<div class="small">المجموع</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td class="py-3 ps-4">
|
||||
<div class="fw-bold" dir="rtl">{{ item.product.name_ar }}</div>
|
||||
<div class="text-muted small">{{ item.product.name_en }}</div>
|
||||
</td>
|
||||
<td class="py-3 text-center">{{ settings.currency_symbol }}{{ item.cost_price|floatformat:3 }}</td>
|
||||
<td class="py-3 text-center">{{ item.quantity|floatformat:2 }}</td>
|
||||
<td class="py-3 text-end pe-4 fw-bold text-primary">{{ settings.currency_symbol }}{{ item.line_total|floatformat:3 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2" class="border-0"></td>
|
||||
<td class="text-center py-3 fw-bold border-top">
|
||||
<div>{% trans "Grand Total" %}</div>
|
||||
<div class="small fw-normal">المجموع الكلي</div>
|
||||
</td>
|
||||
<td class="text-end pe-4 py-3 h5 fw-bold text-primary border-top">{{ settings.currency_symbol }}{{ order.total_amount|floatformat:3 }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Amount in Words -->
|
||||
<div class="mb-5 px-5">
|
||||
<div class="p-3 bg-light rounded-3">
|
||||
<div class="small text-muted fw-bold text-uppercase mb-1">{% trans "Amount in Words" %} / المبلغ بالحروف</div>
|
||||
<div class="fw-bold text-dark">{{ amount_in_words }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
{% if order.notes %}
|
||||
<div class="bg-light p-4 rounded-3 mb-5 mx-5">
|
||||
<h6 class="fw-bold small text-uppercase mb-2 text-muted">{% trans "Notes" %} / ملاحظات</h6>
|
||||
<p class="mb-0 small">{{ order.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-center text-muted small mt-5 border-top pt-4 pb-5">
|
||||
<p class="mb-0">{% trans "Thank you for your business!" %} / شكراً لتعاملكم معنا!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
|
||||
<script>
|
||||
function downloadPDF() {
|
||||
const element = document.getElementById('order-card');
|
||||
const opt = {
|
||||
margin: 0,
|
||||
filename: 'LPO_{{ order.lpo_number|default:order.id }}.pdf',
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true, letterRendering: true },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
|
||||
};
|
||||
html2pdf().set(opt).from(element).save();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
background-color: white !important;
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.container {
|
||||
width: 210mm !important;
|
||||
max-width: 210mm !important;
|
||||
margin: 0 auto !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
#order-card {
|
||||
width: 210mm !important;
|
||||
min-height: 297mm !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.d-print-none { display: none !important; }
|
||||
.p-5 { padding: 15mm !important; }
|
||||
.table-responsive { overflow: visible !important; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
123
core/templates/core/lpo_list.html
Normal file
123
core/templates/core/lpo_list.html
Normal file
@ -0,0 +1,123 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Local Purchase Orders (LPO)" %} | {{ site_settings.business_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-0">{% trans "Purchase Orders (LPO)" %}</h2>
|
||||
<p class="text-muted small mb-0">{% trans "Manage draft orders to suppliers" %}</p>
|
||||
</div>
|
||||
<a href="{% url 'lpo_create' %}" class="btn btn-primary rounded-3 px-4 shadow-sm">
|
||||
<i class="bi bi-plus-circle me-2"></i>{% trans "Create LPO" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
<div class="mb-4">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show rounded-3 shadow-sm border-0" role="alert">
|
||||
<i class="bi bi-info-circle me-2"></i>{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card border-0 shadow-sm rounded-4">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">{% trans "LPO #" %}</th>
|
||||
<th>{% trans "Date" %}</th>
|
||||
<th>{% trans "Supplier" %}</th>
|
||||
<th>{% trans "Total" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th>{% trans "Created By" %}</th>
|
||||
<th class="text-end pe-4">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in orders %}
|
||||
<tr>
|
||||
<td class="ps-4 fw-bold">
|
||||
{{ order.lpo_number|default:order.id }}
|
||||
</td>
|
||||
<td>{{ order.issue_date|date:"Y-m-d" }}</td>
|
||||
<td>{{ order.supplier.name|default:"-" }}</td>
|
||||
<td class="fw-bold text-dark">{{ site_settings.currency_symbol }}{{ order.total_amount|floatformat:3 }}</td>
|
||||
<td>
|
||||
{% if order.status == 'converted' %}
|
||||
<span class="badge bg-success-subtle text-success rounded-pill px-3">{% trans "Converted" %}</span>
|
||||
{% elif order.status == 'sent' %}
|
||||
<span class="badge bg-info-subtle text-info rounded-pill px-3">{% trans "Sent" %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-subtle text-secondary rounded-pill px-3">{% trans "Draft" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted small">
|
||||
<i class="bi bi-person me-1"></i>{{ order.created_by.username|default:"System" }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="btn-group shadow-sm rounded-3">
|
||||
<a href="{% url 'lpo_detail' order.id %}" class="btn btn-sm btn-white border" title="{% trans 'View & Print' %}">
|
||||
<i class="bi bi-printer"></i>
|
||||
</a>
|
||||
{% if order.status != 'converted' %}
|
||||
<form action="{% url 'convert_lpo_to_purchase' order.id %}" method="POST" style="display: inline;" onsubmit="return confirm('{% trans 'Convert this LPO to a Purchase Invoice? This will update stock.' %}');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-white border text-success" title="{% trans 'Convert to Invoice' %}">
|
||||
<i class="bi bi-check2-circle"></i>
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-sm btn-white border text-danger" data-bs-toggle="modal" data-bs-target="#deleteModal{{ order.id }}" title="{% trans 'Delete' %}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div class="modal fade text-start" id="deleteModal{{ order.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-0 shadow rounded-4">
|
||||
<div class="modal-body p-4 text-center">
|
||||
<div class="text-danger mb-3">
|
||||
<i class="bi bi-exclamation-octagon" style="font-size: 3rem;"></i>
|
||||
</div>
|
||||
<h4 class="fw-bold">{% trans "Delete LPO?" %}</h4>
|
||||
<p class="text-muted">{% trans "This action cannot be undone." %}</p>
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{% url 'lpo_delete' order.id %}" class="btn btn-danger rounded-3 py-2">{% trans "Yes, Delete" %}</a>
|
||||
<button type="button" class="btn btn-light rounded-3 py-2" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-5">
|
||||
<img src="https://illustrations.popsy.co/gray/document.svg" alt="Empty" style="width: 200px;" class="mb-3">
|
||||
<p class="text-muted">{% trans "No Purchase Orders found." %}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Pagination if needed -->
|
||||
{% if orders.has_other_pages %}
|
||||
{% include "core/pagination.html" with page_obj=orders %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -133,4 +133,11 @@ urlpatterns = [
|
||||
path('settings/devices/add/', views.add_device, name='add_device'),
|
||||
path('settings/devices/edit/<int:pk>/', views.edit_device, name='edit_device'),
|
||||
path('settings/devices/delete/<int:pk>/', views.delete_device, name='delete_device'),
|
||||
# LPO (Purchase Orders)
|
||||
path('purchases/lpo/', views.lpo_list, name='lpo_list'),
|
||||
path('purchases/lpo/create/', views.lpo_create, name='lpo_create'),
|
||||
path('purchases/lpo/<int:pk>/', views.lpo_detail, name='lpo_detail'),
|
||||
path('purchases/lpo/convert/<int:pk>/', views.convert_lpo_to_purchase, name='convert_lpo_to_purchase'),
|
||||
path('purchases/lpo/delete/<int:pk>/', views.lpo_delete, name='lpo_delete'),
|
||||
path('api/create-lpo/', views.create_lpo_api, name='create_lpo_api'),
|
||||
]
|
||||
131
core/views.py
131
core/views.py
@ -21,7 +21,7 @@ from .models import ( Expense, ExpenseCategory,
|
||||
Purchase, PurchaseItem, PurchasePayment,
|
||||
SaleItem, SalePayment, SystemSetting,
|
||||
Quotation, QuotationItem,
|
||||
SaleReturn, SaleReturnItem, PurchaseReturn, PurchaseReturnItem,
|
||||
SaleReturn, SaleReturnItem, PurchaseReturn, PurchaseReturnItem, PurchaseOrder, PurchaseOrderItem,
|
||||
PaymentMethod, HeldSale, LoyaltyTier, LoyaltyTransaction
|
||||
, Device)
|
||||
import json
|
||||
@ -2617,3 +2617,132 @@ def pos_sync_state(request):
|
||||
# Return a special flag if no state is found yet
|
||||
return JsonResponse({'status': 'empty'}, safe=False)
|
||||
return JsonResponse(data, safe=False)
|
||||
|
||||
|
||||
# --- LPO Views ---
|
||||
|
||||
@login_required
|
||||
def lpo_list(request):
|
||||
orders = PurchaseOrder.objects.all().select_related('supplier', 'created_by').order_by('-created_at')
|
||||
paginator = Paginator(orders, 25)
|
||||
page_number = request.GET.get('page')
|
||||
orders_list = paginator.get_page(page_number)
|
||||
return render(request, 'core/lpo_list.html', {'orders': orders_list})
|
||||
|
||||
@login_required
|
||||
def lpo_create(request):
|
||||
products = Product.objects.filter(is_active=True)
|
||||
suppliers = Supplier.objects.all()
|
||||
return render(request, 'core/lpo_create.html', {
|
||||
'products': products,
|
||||
'suppliers': suppliers,
|
||||
})
|
||||
|
||||
@login_required
|
||||
def lpo_detail(request, pk):
|
||||
order = get_object_or_404(PurchaseOrder, pk=pk)
|
||||
settings = SystemSetting.objects.first()
|
||||
return render(request, 'core/lpo_detail.html', {
|
||||
'order': order,
|
||||
'settings': settings,
|
||||
'amount_in_words': number_to_words_en(order.total_amount)
|
||||
})
|
||||
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def create_lpo_api(request):
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
supplier_id = data.get('supplier_id')
|
||||
lpo_number = data.get('lpo_number', '')
|
||||
items = data.get('items', [])
|
||||
total_amount = data.get('total_amount', 0)
|
||||
notes = data.get('notes', '')
|
||||
issue_date = data.get('issue_date')
|
||||
expected_date = data.get('expected_date')
|
||||
|
||||
supplier = None
|
||||
if supplier_id:
|
||||
supplier = Supplier.objects.get(id=supplier_id)
|
||||
|
||||
order = PurchaseOrder.objects.create(
|
||||
supplier=supplier,
|
||||
lpo_number=lpo_number,
|
||||
total_amount=total_amount,
|
||||
notes=notes,
|
||||
issue_date=issue_date if issue_date else timezone.now(),
|
||||
expected_date=expected_date if expected_date else None,
|
||||
created_by=request.user,
|
||||
status='draft'
|
||||
)
|
||||
|
||||
for item in items:
|
||||
product = Product.objects.get(id=item['id'])
|
||||
PurchaseOrderItem.objects.create(
|
||||
purchase_order=order,
|
||||
product=product,
|
||||
quantity=item['quantity'],
|
||||
cost_price=item['price'],
|
||||
line_total=item['line_total']
|
||||
)
|
||||
|
||||
return JsonResponse({'success': True, 'order_id': order.id})
|
||||
except Exception as e:
|
||||
return JsonResponse({'success': False, 'error': str(e)}, status=400)
|
||||
return JsonResponse({'success': False, 'error': 'Invalid request'}, status=405)
|
||||
|
||||
@login_required
|
||||
def convert_lpo_to_purchase(request, pk):
|
||||
order = get_object_or_404(PurchaseOrder, pk=pk)
|
||||
if order.status == 'converted':
|
||||
messages.warning(request, _("This LPO is already converted."))
|
||||
return redirect('purchase_detail', pk=order.converted_purchase.first().id)
|
||||
|
||||
if request.method == 'POST':
|
||||
# Create Purchase
|
||||
purchase = Purchase.objects.create(
|
||||
supplier=order.supplier,
|
||||
purchase_order=order,
|
||||
total_amount=order.total_amount,
|
||||
paid_amount=0,
|
||||
balance_due=order.total_amount,
|
||||
status='unpaid',
|
||||
notes=f"Converted from LPO #{order.id}. {order.notes}",
|
||||
created_by=request.user
|
||||
)
|
||||
|
||||
# Copy items and update stock
|
||||
for item in order.items.all():
|
||||
PurchaseItem.objects.create(
|
||||
purchase=purchase,
|
||||
product=item.product,
|
||||
quantity=item.quantity,
|
||||
cost_price=item.cost_price,
|
||||
line_total=item.line_total
|
||||
)
|
||||
# Update Stock
|
||||
item.product.stock_quantity += item.quantity
|
||||
item.product.cost_price = item.cost_price # Update cost price
|
||||
item.product.save()
|
||||
|
||||
order.status = 'converted'
|
||||
order.save()
|
||||
|
||||
messages.success(request, _("LPO successfully converted to Purchase Invoice."))
|
||||
return redirect('purchase_detail', pk=purchase.id)
|
||||
|
||||
# Check if this is a GET request for confirmation page, though we usually just post
|
||||
# But user might want to review.
|
||||
return redirect('lpo_detail', pk=order.id)
|
||||
|
||||
@login_required
|
||||
def lpo_delete(request, pk):
|
||||
order = get_object_or_404(PurchaseOrder, pk=pk)
|
||||
if order.status == 'converted':
|
||||
messages.error(request, _("Cannot delete converted LPO."))
|
||||
return redirect('lpo_detail', pk=pk)
|
||||
|
||||
order.delete()
|
||||
messages.success(request, _("LPO deleted."))
|
||||
return redirect('lpo_list')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user