feat: quick-create customer/vendor from unknown email sender
This commit is contained in:
parent
14b13b8239
commit
07584b1b0c
@ -5,13 +5,14 @@ API endpoints for email viewing, classification, and rule management
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, date
|
||||
|
||||
from app.core.database import execute_query, execute_insert, execute_update
|
||||
from app.services.email_processor_service import EmailProcessorService
|
||||
from app.services.email_workflow_service import email_workflow_service
|
||||
from app.services.ollama_service import ollama_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -76,6 +77,8 @@ class EmailDetail(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
attachments: List[EmailAttachment] = []
|
||||
customer_name: Optional[str] = None
|
||||
supplier_name: Optional[str] = None
|
||||
|
||||
|
||||
class EmailRule(BaseModel):
|
||||
@ -204,8 +207,13 @@ async def get_email(email_id: int):
|
||||
"""Get email detail by ID"""
|
||||
try:
|
||||
query = """
|
||||
SELECT * FROM email_messages
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
SELECT em.*,
|
||||
c.name AS customer_name,
|
||||
v.name AS supplier_name
|
||||
FROM email_messages em
|
||||
LEFT JOIN customers c ON em.customer_id = c.id
|
||||
LEFT JOIN vendors v ON em.supplier_id = v.id
|
||||
WHERE em.id = %s AND em.deleted_at IS NULL
|
||||
"""
|
||||
result = execute_query(query, (email_id,))
|
||||
logger.info(f"🔍 Query result type: {type(result)}, length: {len(result) if result else 0}")
|
||||
@ -334,6 +342,161 @@ async def update_email(email_id: int, status: Optional[str] = None):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/emails/{email_id}/link")
|
||||
async def link_email(email_id: int, payload: Dict):
|
||||
"""Link email to a customer and/or vendor/supplier"""
|
||||
try:
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if 'customer_id' in payload:
|
||||
updates.append("customer_id = %s")
|
||||
params.append(payload['customer_id'])
|
||||
|
||||
if 'supplier_id' in payload:
|
||||
updates.append("supplier_id = %s")
|
||||
params.append(payload['supplier_id'])
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="Ingen felter at opdatere")
|
||||
|
||||
params.append(email_id)
|
||||
query = f"UPDATE email_messages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s"
|
||||
execute_update(query, tuple(params))
|
||||
|
||||
logger.info(f"✅ Linked email {email_id}: {payload}")
|
||||
return {"success": True, "message": "Email linket"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error linking email {email_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/emails/{email_id}/extract-vendor-suggestion")
|
||||
async def extract_vendor_suggestion(email_id: int):
|
||||
"""
|
||||
Forsøger at udtrække leverandørinfo fra email body og vedhæftede fakturaer.
|
||||
Returnerer forslag til navn, CVR, adresse, telefon, email, domæne.
|
||||
"""
|
||||
import re
|
||||
import os
|
||||
|
||||
try:
|
||||
# Hent email
|
||||
email_result = execute_query(
|
||||
"SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL",
|
||||
(email_id,)
|
||||
)
|
||||
if not email_result:
|
||||
raise HTTPException(status_code=404, detail="Email ikke fundet")
|
||||
email = email_result[0]
|
||||
|
||||
# Saml tekst fra body + vedhæftede PDF-filer
|
||||
text_parts = []
|
||||
if email.get('body_text'):
|
||||
text_parts.append(email['body_text'])
|
||||
|
||||
attachments = execute_query(
|
||||
"SELECT * FROM email_attachments WHERE email_id = %s ORDER BY id",
|
||||
(email_id,)
|
||||
)
|
||||
for att in (attachments or []):
|
||||
file_path = att.get('file_path')
|
||||
if file_path and os.path.exists(file_path):
|
||||
content_type = att.get('content_type', '')
|
||||
if 'pdf' in content_type or file_path.lower().endswith('.pdf'):
|
||||
try:
|
||||
pdf_text = await ollama_service._extract_text_from_file(file_path)
|
||||
if pdf_text:
|
||||
text_parts.append(f"[PDF: {att['filename']}]\n{pdf_text}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Kunne ikke læse PDF {file_path}: {e}")
|
||||
|
||||
combined_text = "\n\n".join(text_parts)
|
||||
|
||||
suggestion = {
|
||||
"name": email.get('sender_name') or '',
|
||||
"email": email.get('sender_email') or '',
|
||||
"cvr_number": None,
|
||||
"phone": None,
|
||||
"address": None,
|
||||
"domain": None,
|
||||
"source": "regex"
|
||||
}
|
||||
|
||||
# Regex fallback: uddrag CVR (8 cifre efter CVR/Momsnr/DK)
|
||||
cvr_match = re.search(
|
||||
r'(?:CVR|Cvr|cvr|Momsnr\.?|DK)[\s:.-]*([0-9]{8})',
|
||||
combined_text
|
||||
)
|
||||
if cvr_match:
|
||||
suggestion['cvr_number'] = cvr_match.group(1)
|
||||
|
||||
# Regex: telefon (dansk format)
|
||||
phone_match = re.search(
|
||||
r'(?:Tlf|Tel|Telefon|Phone)[\s.:]*([+\d][\d\s\-().]{6,15})',
|
||||
combined_text, re.IGNORECASE
|
||||
)
|
||||
if phone_match:
|
||||
suggestion['phone'] = phone_match.group(1).strip()
|
||||
|
||||
# Domæne fra sender email
|
||||
if email.get('sender_email') and '@' in email['sender_email']:
|
||||
suggestion['domain'] = email['sender_email'].split('@')[1]
|
||||
|
||||
# Brug AI hvis vi har tekst fra PDF
|
||||
if len(combined_text) > 100:
|
||||
try:
|
||||
from app.core.config import settings
|
||||
own_cvr = getattr(settings, 'OWN_CVR', '')
|
||||
|
||||
prompt = f"""OPGAVE: Udtræk leverandørens firmainfo fra denne tekst.
|
||||
TEKSTEN er body/footer fra en faktura-email eller vedhæftet faktura.
|
||||
RETURNER KUN VALID JSON - ingen forklaring!
|
||||
|
||||
{{
|
||||
\"name\": \"Firmanavn ApS\",
|
||||
\"cvr_number\": \"12345678\",
|
||||
\"address\": \"Vejnavn 1, 2000 By\",
|
||||
\"phone\": \"12345678\",
|
||||
\"email\": \"kontakt@firma.dk\",
|
||||
\"domain\": \"firma.dk\"
|
||||
}}
|
||||
|
||||
REGLER:
|
||||
- name: LEVERANDØRENS firmanavn (ikke køber, ikke BMC)
|
||||
- cvr_number: 8-cifret CVR - IGNORER {own_cvr} (det er køber)
|
||||
- Sæt null hvis ikke fundet
|
||||
- KUN JSON output!
|
||||
|
||||
TEKST (max 3000 tegn):
|
||||
{combined_text[:3000]}
|
||||
|
||||
KUN JSON:"""
|
||||
|
||||
ai_result = await ollama_service.extract_from_text(prompt)
|
||||
if ai_result and isinstance(ai_result, dict):
|
||||
# Merge AI resultat ind i suggestion (AI prioriteres over regex)
|
||||
for field in ('name', 'cvr_number', 'address', 'phone', 'email', 'domain'):
|
||||
val = ai_result.get(field)
|
||||
if val and val not in (None, 'null', '', 'N/A'):
|
||||
suggestion[field] = str(val).strip()
|
||||
suggestion['source'] = 'ai'
|
||||
logger.info(f"✅ AI vendor suggestion for email {email_id}: {suggestion}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ AI udtræk fejlede, bruger regex-resultat: {e}")
|
||||
|
||||
return suggestion
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ extract-vendor-suggestion fejlede for email {email_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/emails/{email_id}")
|
||||
async def delete_email(email_id: int):
|
||||
"""Soft delete email"""
|
||||
|
||||
@ -1763,6 +1763,34 @@ function renderEmailAnalysis(email) {
|
||||
|
||||
// Opdater kun AI Analysis tab indholdet - ikke hele sidebar
|
||||
aiAnalysisTab.innerHTML = `
|
||||
${!email.customer_id && !email.supplier_id ? `
|
||||
<div class="analysis-card border border-warning">
|
||||
<h6 class="text-warning"><i class="bi bi-person-question-fill me-2"></i>Ukendt Afsender</h6>
|
||||
<div class="text-muted small mb-2">
|
||||
<i class="bi bi-envelope me-1"></i>${escapeHtml(email.sender_email || '')}
|
||||
${email.sender_name ? `<br><i class="bi bi-person me-1"></i>${escapeHtml(email.sender_name)}` : ''}
|
||||
</div>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary w-100"
|
||||
onclick="quickCreateCustomer(${email.id}, '${escapeHtml(email.sender_name || '')}', '${escapeHtml(email.sender_email || '')}')">
|
||||
<i class="bi bi-person-plus me-2"></i>Opret som Kunde
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary w-100"
|
||||
onclick="quickCreateVendor(${email.id}, '${escapeHtml(email.sender_name || '')}', '${escapeHtml(email.sender_email || '')}')">
|
||||
<i class="bi bi-shop me-2"></i>Opret som Leverandør
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<div class="analysis-card">
|
||||
<h6 class="text-success"><i class="bi bi-person-check-fill me-2"></i>Linket Til</h6>
|
||||
<ul class="metadata-list">
|
||||
${email.customer_id ? `<li class="metadata-item"><div class="metadata-label">Kunde</div><div class="metadata-value">${escapeHtml(email.customer_name || '#' + email.customer_id)}</div></li>` : ''}
|
||||
${email.supplier_id ? `<li class="metadata-item"><div class="metadata-label">Leverandør</div><div class="metadata-value">${escapeHtml(email.supplier_name || '#' + email.supplier_id)}</div></li>` : ''}
|
||||
</ul>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${getClassificationActions(email) ? `
|
||||
<div class="analysis-card">
|
||||
<h6><i class="bi bi-lightning-charge-fill me-2"></i>Hurtig Action</h6>
|
||||
@ -2251,6 +2279,141 @@ async function linkToCustomer(emailId) {
|
||||
showError('Kunde-linking er ikke implementeret endnu');
|
||||
}
|
||||
|
||||
// ─── Quick Create Customer ────────────────────────────────────────────────
|
||||
function quickCreateCustomer(emailId, senderName, senderEmail) {
|
||||
document.getElementById('qcEmailId').value = emailId;
|
||||
document.getElementById('qcCustomerName').value = senderName || '';
|
||||
document.getElementById('qcCustomerEmail').value = senderEmail || '';
|
||||
document.getElementById('qcCustomerPhone').value = '';
|
||||
document.getElementById('qcCustomerStatus').textContent = '';
|
||||
const modal = new bootstrap.Modal(document.getElementById('quickCreateCustomerModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
async function submitQuickCustomer() {
|
||||
const emailId = document.getElementById('qcEmailId').value;
|
||||
const name = document.getElementById('qcCustomerName').value.trim();
|
||||
const email = document.getElementById('qcCustomerEmail').value.trim();
|
||||
const phone = document.getElementById('qcCustomerPhone').value.trim();
|
||||
const statusEl = document.getElementById('qcCustomerStatus');
|
||||
|
||||
if (!name) { statusEl.className = 'text-danger small'; statusEl.textContent = 'Navn er påkrævet'; return; }
|
||||
statusEl.className = 'text-muted small'; statusEl.textContent = 'Opretter…';
|
||||
|
||||
try {
|
||||
// Create customer
|
||||
const custResp = await fetch('/api/v1/customers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email: email || null, phone: phone || null })
|
||||
});
|
||||
if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede');
|
||||
const customer = await custResp.json();
|
||||
|
||||
// Link email
|
||||
await fetch(`/api/v1/emails/${emailId}/link`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ customer_id: customer.id })
|
||||
});
|
||||
|
||||
bootstrap.Modal.getInstance(document.getElementById('quickCreateCustomerModal')).hide();
|
||||
showSuccess(`Kunde "${name}" oprettet og linket`);
|
||||
loadEmailDetail(parseInt(emailId));
|
||||
} catch (e) {
|
||||
statusEl.className = 'text-danger small';
|
||||
statusEl.textContent = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Quick Create Vendor ──────────────────────────────────────────────────
|
||||
async function quickCreateVendor(emailId, senderName, senderEmail) {
|
||||
// Reset form
|
||||
document.getElementById('qvEmailId').value = emailId;
|
||||
document.getElementById('qvVendorName').value = senderName || '';
|
||||
document.getElementById('qvVendorEmail').value = senderEmail || '';
|
||||
document.getElementById('qvVendorCVR').value = '';
|
||||
document.getElementById('qvVendorPhone').value = '';
|
||||
document.getElementById('qvVendorAddress').value = '';
|
||||
document.getElementById('qvVendorDomain').value = senderEmail && senderEmail.includes('@') ? senderEmail.split('@')[1] : '';
|
||||
document.getElementById('qvVendorStatus').textContent = '';
|
||||
document.getElementById('qvAiStatus').innerHTML = '<span class="text-muted small"><i class="bi bi-hourglass-split me-1"></i>Henter info fra email/bilag…</span>';
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('quickCreateVendorModal'));
|
||||
modal.show();
|
||||
|
||||
// Attempt AI extraction in background
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/emails/${emailId}/extract-vendor-suggestion`, { method: 'POST' });
|
||||
if (resp.ok) {
|
||||
const s = await resp.json();
|
||||
if (s.name && s.name !== senderName) document.getElementById('qvVendorName').value = s.name;
|
||||
if (s.cvr_number) document.getElementById('qvVendorCVR').value = s.cvr_number;
|
||||
if (s.phone) document.getElementById('qvVendorPhone').value = s.phone;
|
||||
if (s.address) document.getElementById('qvVendorAddress').value = s.address;
|
||||
if (s.domain) document.getElementById('qvVendorDomain').value = s.domain;
|
||||
if (s.email && !document.getElementById('qvVendorEmail').value)
|
||||
document.getElementById('qvVendorEmail').value = s.email;
|
||||
|
||||
const srcLabel = s.source === 'ai' ? '🤖 AI' : '🔍 Regex';
|
||||
document.getElementById('qvAiStatus').innerHTML =
|
||||
`<span class="text-success small"><i class="bi bi-check-circle me-1"></i>${srcLabel} – felter preudfyldt</span>`;
|
||||
} else {
|
||||
document.getElementById('qvAiStatus').innerHTML =
|
||||
'<span class="text-muted small"><i class="bi bi-info-circle me-1"></i>Ingen automatisk data fundet</span>';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('qvAiStatus').innerHTML =
|
||||
'<span class="text-muted small"><i class="bi bi-info-circle me-1"></i>Udfyld manuelt</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitQuickVendor() {
|
||||
const emailId = document.getElementById('qvEmailId').value;
|
||||
const name = document.getElementById('qvVendorName').value.trim();
|
||||
const email = document.getElementById('qvVendorEmail').value.trim();
|
||||
const cvr = document.getElementById('qvVendorCVR').value.trim();
|
||||
const phone = document.getElementById('qvVendorPhone').value.trim();
|
||||
const address = document.getElementById('qvVendorAddress').value.trim();
|
||||
const domain = document.getElementById('qvVendorDomain').value.trim();
|
||||
const statusEl = document.getElementById('qvVendorStatus');
|
||||
|
||||
if (!name) { statusEl.className = 'text-danger small'; statusEl.textContent = 'Navn er påkrævet'; return; }
|
||||
statusEl.className = 'text-muted small'; statusEl.textContent = 'Opretter…';
|
||||
|
||||
try {
|
||||
// Create vendor
|
||||
const vendResp = await fetch('/api/v1/vendors', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email: email || null,
|
||||
cvr_number: cvr || null,
|
||||
phone: phone || null,
|
||||
address: address || null,
|
||||
domain: domain || null
|
||||
})
|
||||
});
|
||||
if (!vendResp.ok) throw new Error((await vendResp.json()).detail || 'Opret fejlede');
|
||||
const vendor = await vendResp.json();
|
||||
|
||||
// Link email
|
||||
await fetch(`/api/v1/emails/${emailId}/link`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ supplier_id: vendor.id })
|
||||
});
|
||||
|
||||
bootstrap.Modal.getInstance(document.getElementById('quickCreateVendorModal')).hide();
|
||||
showSuccess(`Leverandør "${name}" oprettet og linket`);
|
||||
loadEmailDetail(parseInt(emailId));
|
||||
} catch (e) {
|
||||
statusEl.className = 'text-danger small';
|
||||
statusEl.textContent = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveClassification() {
|
||||
if (!currentEmailId) return;
|
||||
|
||||
@ -4115,4 +4278,88 @@ async function uploadEmailFiles(files) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Quick Create Customer Modal -->
|
||||
<div class="modal fade" id="quickCreateCustomerModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-person-plus me-2"></i>Opret Kunde</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="qcEmailId">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Navn <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="qcCustomerName" placeholder="Firmanavn eller kontaktperson">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Email</label>
|
||||
<input type="email" class="form-control" id="qcCustomerEmail">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Telefon</label>
|
||||
<input type="text" class="form-control" id="qcCustomerPhone">
|
||||
</div>
|
||||
<div id="qcCustomerStatus"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||
<button class="btn btn-primary" onclick="submitQuickCustomer()">
|
||||
<i class="bi bi-person-plus me-2"></i>Opret & Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Create Vendor Modal -->
|
||||
<div class="modal fade" id="quickCreateVendorModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-shop me-2"></i>Opret Leverandør</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="qvEmailId">
|
||||
<div id="qvAiStatus" class="mb-3"></div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Navn <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="qvVendorName" placeholder="Firmanavn">
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">CVR-nummer</label>
|
||||
<input type="text" class="form-control" id="qvVendorCVR" maxlength="8" placeholder="12345678">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">Telefon</label>
|
||||
<input type="text" class="form-control" id="qvVendorPhone">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Email</label>
|
||||
<input type="email" class="form-control" id="qvVendorEmail">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Adresse</label>
|
||||
<input type="text" class="form-control" id="qvVendorAddress" placeholder="Vejnavn 1, 1234 By">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Domæne</label>
|
||||
<input type="text" class="form-control" id="qvVendorDomain" placeholder="firma.dk">
|
||||
</div>
|
||||
<div id="qvVendorStatus"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||
<button class="btn btn-primary" onclick="submitQuickVendor()">
|
||||
<i class="bi bi-shop me-2"></i>Opret & Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@ -74,9 +74,16 @@ class VendorBase(BaseModel):
|
||||
domain: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
email_pattern: Optional[str] = None
|
||||
contact_person: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
priority: Optional[int] = 100
|
||||
notes: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class VendorCreate(VendorBase):
|
||||
@ -100,10 +107,9 @@ class VendorUpdate(BaseModel):
|
||||
class Vendor(VendorBase):
|
||||
"""Full vendor schema"""
|
||||
id: int
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user