mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 12:50:11 -05:00
feat: Enhance invoice and quote management system
- Improve invoice model with additional fields and methods - Enhance quote model with better validation and relationships - Add invoice repository for data access layer abstraction - Update invoice and quote routes with improved functionality - Add quote service for business logic separation - Improve quote view and edit templates with better UX
This commit is contained in:
+19
-6
@@ -285,14 +285,25 @@ class Invoice(db.Model):
|
||||
def generate_invoice_number(cls):
|
||||
"""Generate a unique invoice number"""
|
||||
from datetime import datetime
|
||||
from app.models import Settings
|
||||
|
||||
# Format: INV-YYYYMMDD-XXX
|
||||
# Get settings for invoice prefix and start number
|
||||
settings = Settings.get_settings()
|
||||
prefix = "INV" # Default prefix
|
||||
start_number = 1 # Default start number
|
||||
|
||||
if settings:
|
||||
prefix = getattr(settings, "invoice_prefix", "INV") or "INV"
|
||||
start_number = getattr(settings, "invoice_start_number", 1) or 1
|
||||
|
||||
# Format: {prefix}-YYYYMMDD-XXX
|
||||
today = datetime.utcnow()
|
||||
date_prefix = today.strftime("%Y%m%d")
|
||||
search_pattern = f"{prefix}-{date_prefix}-%"
|
||||
|
||||
# Find the next available number for today
|
||||
# Find the next available number for today with the custom prefix
|
||||
existing = (
|
||||
cls.query.filter(cls.invoice_number.like(f"INV-{date_prefix}-%"))
|
||||
cls.query.filter(cls.invoice_number.like(search_pattern))
|
||||
.order_by(cls.invoice_number.desc())
|
||||
.first()
|
||||
)
|
||||
@@ -302,12 +313,14 @@ class Invoice(db.Model):
|
||||
try:
|
||||
last_num = int(existing.invoice_number.split("-")[-1])
|
||||
next_num = last_num + 1
|
||||
# Ensure next_num is at least start_number
|
||||
next_num = max(next_num, start_number)
|
||||
except (ValueError, IndexError):
|
||||
next_num = 1
|
||||
next_num = start_number
|
||||
else:
|
||||
next_num = 1
|
||||
next_num = start_number
|
||||
|
||||
return f"INV-{date_prefix}-{next_num:03d}"
|
||||
return f"{prefix}-{date_prefix}-{next_num:03d}"
|
||||
|
||||
|
||||
class InvoiceItem(db.Model):
|
||||
|
||||
@@ -68,14 +68,24 @@ class InvoiceRepository(BaseRepository[Invoice]):
|
||||
def generate_invoice_number(self) -> str:
|
||||
"""Generate a unique invoice number"""
|
||||
from datetime import datetime
|
||||
from app.models import Settings
|
||||
|
||||
# Format: INV-YYYYMMDD-XXXX
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
prefix = f"INV-{today}-"
|
||||
# Get settings for invoice prefix and start number
|
||||
settings = Settings.get_settings()
|
||||
prefix = "INV" # Default prefix
|
||||
start_number = 1 # Default start number
|
||||
|
||||
# Find the highest number for today
|
||||
if settings:
|
||||
prefix = getattr(settings, "invoice_prefix", "INV") or "INV"
|
||||
start_number = getattr(settings, "invoice_start_number", 1) or 1
|
||||
|
||||
# Format: {prefix}-YYYYMMDD-XXX
|
||||
today = datetime.utcnow().strftime("%Y%m%d")
|
||||
search_pattern = f"{prefix}-{today}-%"
|
||||
|
||||
# Find the highest number for today with the custom prefix
|
||||
last_invoice = (
|
||||
self.model.query.filter(Invoice.invoice_number.like(f"{prefix}%"))
|
||||
self.model.query.filter(Invoice.invoice_number.like(search_pattern))
|
||||
.order_by(Invoice.invoice_number.desc())
|
||||
.first()
|
||||
)
|
||||
@@ -84,12 +94,14 @@ class InvoiceRepository(BaseRepository[Invoice]):
|
||||
try:
|
||||
last_num = int(last_invoice.invoice_number.split("-")[-1])
|
||||
next_num = last_num + 1
|
||||
# Ensure next_num is at least start_number
|
||||
next_num = max(next_num, start_number)
|
||||
except (ValueError, IndexError):
|
||||
next_num = 1
|
||||
next_num = start_number
|
||||
else:
|
||||
next_num = 1
|
||||
next_num = start_number
|
||||
|
||||
return f"{prefix}{next_num:04d}"
|
||||
return f"{prefix}-{today}-{next_num:03d}"
|
||||
|
||||
def mark_as_sent(self, invoice_id: int) -> Optional[Invoice]:
|
||||
"""Mark an invoice as sent"""
|
||||
|
||||
+21
-16
@@ -1029,57 +1029,62 @@ def export_invoice_csv(invoice_id):
|
||||
@login_required
|
||||
def export_invoice_pdf(invoice_id):
|
||||
"""Export invoice as PDF with optional page size selection"""
|
||||
logger.info(f"Invoice PDF export requested - Invoice ID: {invoice_id}, User: {current_user.username}")
|
||||
|
||||
current_app.logger.info(f"[PDF_EXPORT] Action: export_request, InvoiceID: {invoice_id}, User: {current_user.username}")
|
||||
|
||||
invoice = Invoice.query.get_or_404(invoice_id)
|
||||
logger.debug(f"Invoice found: {invoice.invoice_number}")
|
||||
current_app.logger.info(f"[PDF_EXPORT] Invoice found: {invoice.invoice_number}, Status: {invoice.status}")
|
||||
|
||||
if not current_user.is_admin and invoice.created_by != current_user.id:
|
||||
logger.warning(f"Permission denied for invoice {invoice_id} by user {current_user.username}")
|
||||
current_app.logger.warning(f"[PDF_EXPORT] Permission denied - InvoiceID: {invoice_id}, User: {current_user.username}")
|
||||
flash(_("You do not have permission to export this invoice"), "error")
|
||||
return redirect(request.referrer or url_for("invoices.list_invoices"))
|
||||
|
||||
# Get page size from query parameter, default to A4
|
||||
page_size = request.args.get("size", "A4")
|
||||
logger.debug(f"Page size from request: '{page_size}'")
|
||||
page_size_raw = request.args.get("size", "A4")
|
||||
current_app.logger.info(f"[PDF_EXPORT] PageSize from query param: '{page_size_raw}', InvoiceID: {invoice_id}")
|
||||
|
||||
# Validate page size
|
||||
valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
|
||||
if page_size not in valid_sizes:
|
||||
logger.warning(f"Invalid page size '{page_size}', defaulting to A4")
|
||||
if page_size_raw not in valid_sizes:
|
||||
current_app.logger.warning(f"[PDF_EXPORT] Invalid page size '{page_size_raw}', defaulting to A4, InvoiceID: {invoice_id}")
|
||||
page_size = "A4"
|
||||
else:
|
||||
page_size = page_size_raw
|
||||
|
||||
logger.debug(f"Final page size: '{page_size}'")
|
||||
current_app.logger.info(f"[PDF_EXPORT] Final validated PageSize: '{page_size}', InvoiceID: {invoice_id}, InvoiceNumber: {invoice.invoice_number}")
|
||||
|
||||
try:
|
||||
from app.utils.pdf_generator import InvoicePDFGenerator
|
||||
|
||||
settings = Settings.get_settings()
|
||||
logger.debug(f"Creating InvoicePDFGenerator with page_size='{page_size}'")
|
||||
current_app.logger.info(f"[PDF_EXPORT] Creating InvoicePDFGenerator - PageSize: '{page_size}', InvoiceID: {invoice_id}")
|
||||
pdf_generator = InvoicePDFGenerator(invoice, settings=settings, page_size=page_size)
|
||||
logger.debug("Calling pdf_generator.generate_pdf()")
|
||||
current_app.logger.info(f"[PDF_EXPORT] Starting PDF generation - PageSize: '{page_size}', InvoiceID: {invoice_id}")
|
||||
pdf_bytes = pdf_generator.generate_pdf()
|
||||
logger.info(f"PDF generated successfully, size: {len(pdf_bytes)} bytes")
|
||||
pdf_size_bytes = len(pdf_bytes)
|
||||
current_app.logger.info(f"[PDF_EXPORT] PDF generation completed successfully - PageSize: '{page_size}', InvoiceID: {invoice_id}, PDFSize: {pdf_size_bytes} bytes")
|
||||
filename = f"invoice_{invoice.invoice_number}_{page_size}.pdf"
|
||||
current_app.logger.info(f"[PDF_EXPORT] Returning PDF file - Filename: '{filename}', PageSize: '{page_size}', InvoiceID: {invoice_id}")
|
||||
return send_file(io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=filename)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
logger.error(f"Exception in PDF generation: {e}", exc_info=True)
|
||||
current_app.logger.error(f"[PDF_EXPORT] Exception in PDF generation - PageSize: '{page_size}', InvoiceID: {invoice_id}, Error: {str(e)}", exc_info=True)
|
||||
try:
|
||||
logger.info("Falling back to InvoicePDFGeneratorFallback")
|
||||
current_app.logger.warning(f"[PDF_EXPORT] Falling back to InvoicePDFGeneratorFallback - PageSize: '{page_size}', InvoiceID: {invoice_id}")
|
||||
from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback
|
||||
|
||||
settings = Settings.get_settings()
|
||||
pdf_generator = InvoicePDFGeneratorFallback(invoice, settings=settings)
|
||||
pdf_bytes = pdf_generator.generate_pdf()
|
||||
logger.info("Fallback PDF generated successfully")
|
||||
pdf_size_bytes = len(pdf_bytes)
|
||||
current_app.logger.info(f"[PDF_EXPORT] Fallback PDF generated successfully - PageSize: '{page_size}', InvoiceID: {invoice_id}, PDFSize: {pdf_size_bytes} bytes")
|
||||
filename = f"invoice_{invoice.invoice_number}_{page_size}.pdf"
|
||||
return send_file(
|
||||
io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=filename
|
||||
)
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"Fallback PDF generation also failed: {fallback_error}", exc_info=True)
|
||||
current_app.logger.error(f"[PDF_EXPORT] Fallback PDF generation also failed - PageSize: '{page_size}', InvoiceID: {invoice_id}, Error: {str(fallback_error)}", exc_info=True)
|
||||
flash(
|
||||
_("PDF generation failed: %(err)s. Fallback also failed: %(fb)s", err=str(e), fb=str(fallback_error)),
|
||||
"error",
|
||||
|
||||
+23
-5
@@ -266,9 +266,9 @@ def view_quote(quote_id):
|
||||
@admin_or_permission_required("edit_quotes")
|
||||
def edit_quote(quote_id):
|
||||
"""Edit an quote"""
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
quote = Quote.query.options(joinedload(Quote.client), joinedload(Quote.items)).filter_by(id=quote_id).first_or_404()
|
||||
quote = Quote.query.options(joinedload(Quote.client), selectinload(Quote.items)).filter_by(id=quote_id).first_or_404()
|
||||
|
||||
# Only allow editing draft quotes
|
||||
if quote.status != "draft":
|
||||
@@ -1185,19 +1185,29 @@ def save_template_from_quote(template_id):
|
||||
@login_required
|
||||
def export_quote_pdf(quote_id):
|
||||
"""Export quote as PDF"""
|
||||
current_app.logger.info(f"[PDF_EXPORT] Action: export_request, QuoteID: {quote_id}, User: {current_user.username}")
|
||||
|
||||
quote = Quote.query.get_or_404(quote_id)
|
||||
current_app.logger.info(f"[PDF_EXPORT] Quote found: {quote.quote_number}, Status: {quote.status}")
|
||||
|
||||
if not current_user.is_admin and quote.created_by != current_user.id:
|
||||
current_app.logger.warning(f"[PDF_EXPORT] Permission denied - QuoteID: {quote_id}, User: {current_user.username}")
|
||||
flash(_("You do not have permission to export this quote"), "error")
|
||||
return redirect(request.referrer or url_for("quotes.list_quotes"))
|
||||
|
||||
# Get page size from query parameter, default to A4
|
||||
page_size = request.args.get("size", "A4")
|
||||
page_size_raw = request.args.get("size", "A4")
|
||||
current_app.logger.info(f"[PDF_EXPORT] PageSize from query param: '{page_size_raw}', QuoteID: {quote_id}")
|
||||
|
||||
# Validate page size
|
||||
valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
|
||||
if page_size not in valid_sizes:
|
||||
if page_size_raw not in valid_sizes:
|
||||
current_app.logger.warning(f"[PDF_EXPORT] Invalid page size '{page_size_raw}', defaulting to A4, QuoteID: {quote_id}")
|
||||
page_size = "A4"
|
||||
else:
|
||||
page_size = page_size_raw
|
||||
|
||||
current_app.logger.info(f"[PDF_EXPORT] Final validated PageSize: '{page_size}', QuoteID: {quote_id}, QuoteNumber: {quote.quote_number}")
|
||||
|
||||
try:
|
||||
from app.utils.pdf_generator import QuotePDFGenerator
|
||||
@@ -1206,12 +1216,18 @@ def export_quote_pdf(quote_id):
|
||||
from flask import send_file
|
||||
|
||||
settings = Settings.get_settings()
|
||||
current_app.logger.info(f"[PDF_EXPORT] Creating QuotePDFGenerator - PageSize: '{page_size}', QuoteID: {quote_id}")
|
||||
pdf_generator = QuotePDFGenerator(quote, settings=settings, page_size=page_size)
|
||||
current_app.logger.info(f"[PDF_EXPORT] Starting PDF generation - PageSize: '{page_size}', QuoteID: {quote_id}")
|
||||
pdf_bytes = pdf_generator.generate_pdf()
|
||||
pdf_size_bytes = len(pdf_bytes)
|
||||
current_app.logger.info(f"[PDF_EXPORT] PDF generation completed successfully - PageSize: '{page_size}', QuoteID: {quote_id}, PDFSize: {pdf_size_bytes} bytes")
|
||||
filename = f"quote_{quote.quote_number}_{page_size}.pdf"
|
||||
current_app.logger.info(f"[PDF_EXPORT] Returning PDF file - Filename: '{filename}', PageSize: '{page_size}', QuoteID: {quote_id}")
|
||||
return send_file(io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=filename)
|
||||
except ImportError:
|
||||
# Fallback if QuotePDFGenerator doesn't exist yet
|
||||
current_app.logger.warning(f"[PDF_EXPORT] QuotePDFGenerator import failed, using fallback - PageSize: '{page_size}', QuoteID: {quote_id}")
|
||||
from app.utils.pdf_generator_fallback import QuotePDFGeneratorFallback
|
||||
from app.models import Settings
|
||||
import io
|
||||
@@ -1220,10 +1236,12 @@ def export_quote_pdf(quote_id):
|
||||
settings = Settings.get_settings()
|
||||
pdf_generator = QuotePDFGeneratorFallback(quote, settings=settings)
|
||||
pdf_bytes = pdf_generator.generate_pdf()
|
||||
pdf_size_bytes = len(pdf_bytes)
|
||||
current_app.logger.info(f"[PDF_EXPORT] Fallback PDF generated successfully - PageSize: '{page_size}', QuoteID: {quote_id}, PDFSize: {pdf_size_bytes} bytes")
|
||||
filename = f"quote_{quote.quote_number}_{page_size}.pdf"
|
||||
return send_file(io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=filename)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error generating quote PDF: {e}", exc_info=True)
|
||||
current_app.logger.error(f"[PDF_EXPORT] Exception in PDF generation - PageSize: '{page_size}', QuoteID: {quote_id}, Error: {str(e)}", exc_info=True)
|
||||
flash(_("Error generating PDF: %(error)s", error=str(e)), "error")
|
||||
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
|
||||
|
||||
|
||||
@@ -140,9 +140,9 @@ class QuoteService:
|
||||
Returns:
|
||||
Quote with eagerly loaded relations, or None if not found
|
||||
"""
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
query = Quote.query.options(joinedload(Quote.client), joinedload(Quote.items))
|
||||
query = Quote.query.options(joinedload(Quote.client), selectinload(Quote.items))
|
||||
|
||||
# Permission check
|
||||
if not is_admin and user_id:
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<div class="flex items-center justify-between mb-4 pb-2 border-b border-border-light dark:border-border-dark">
|
||||
<h2 class="text-xl font-semibold flex items-center">
|
||||
<i class="fas fa-list mr-2 text-primary"></i>{{ _('Quote Items') }}
|
||||
<span id="items-count" class="ml-2 px-2 py-0.5 text-xs bg-primary/10 text-primary rounded-full">{{ quote.items.count() if quote.items else 0 }}</span>
|
||||
<span id="items-count" class="ml-2 px-2 py-0.5 text-xs bg-primary/10 text-primary rounded-full">{{ quote.items|length if quote.items else 0 }}</span>
|
||||
</h2>
|
||||
<button type="button" id="add-item" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition shadow-sm">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Add Item') }}
|
||||
|
||||
@@ -24,9 +24,19 @@
|
||||
<i class="fas fa-copy mr-2"></i>{{ _('Duplicate') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('quotes.export_quote_pdf', quote_id=quote.id) }}" class="inline-block px-4 py-2 rounded-lg bg-green-600 text-white mt-4 md:mt-0 hover:bg-green-700 transition-colors">
|
||||
<i class="fas fa-file-pdf mr-2"></i>{{ _('Export PDF') }}
|
||||
</a>
|
||||
<div class="flex gap-2 items-center">
|
||||
<select id="pdf-size-selector" class="bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
<option value="A4">A4</option>
|
||||
<option value="Letter">Letter</option>
|
||||
<option value="Legal">Legal</option>
|
||||
<option value="A3">A3</option>
|
||||
<option value="A5">A5</option>
|
||||
<option value="Tabloid">Tabloid</option>
|
||||
</select>
|
||||
<a id="export-pdf-link" href="{{ url_for('quotes.export_quote_pdf', quote_id=quote.id) }}" class="inline-block px-4 py-2 rounded-lg bg-green-600 text-white mt-4 md:mt-0 hover:bg-green-700 transition-colors">
|
||||
<i class="fas fa-file-pdf mr-2"></i>{{ _('Export PDF') }}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" onclick="showSendEmailModal()" class="px-4 py-2 rounded-lg bg-blue-500 text-white mt-4 md:mt-0 hover:bg-blue-600 transition-colors">
|
||||
<i class="fas fa-envelope mr-2"></i>{{ _('Send Email') }}
|
||||
</button>
|
||||
@@ -466,6 +476,24 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// PDF Size Selector
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const sizeSelector = document.getElementById('pdf-size-selector');
|
||||
const exportLink = document.getElementById('export-pdf-link');
|
||||
if (sizeSelector && exportLink) {
|
||||
// Initialize export link with default size (A4)
|
||||
const defaultSize = sizeSelector.value || 'A4';
|
||||
const baseUrl = "{{ url_for('quotes.export_quote_pdf', quote_id=quote.id) }}";
|
||||
exportLink.href = baseUrl + '?size=' + defaultSize;
|
||||
|
||||
// Update export link when size changes
|
||||
sizeSelector.addEventListener('change', function() {
|
||||
const size = this.value;
|
||||
exportLink.href = baseUrl + '?size=' + size;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function showSendEmailModal() {
|
||||
document.getElementById('sendEmailModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user