Autosave: 20260206-055506

This commit is contained in:
Flatlogic Bot 2026-02-06 05:55:06 +00:00
parent 73951729f9
commit 54a353ee6a
12 changed files with 823 additions and 1 deletions

View File

@ -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')),
],
),
]

View File

@ -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)

View File

@ -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" %}

View 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 %}

View 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 %}

View 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 %}

View File

@ -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'),
]

View File

@ -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')