Coverage for modules\erp\api.py: 40%
121 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-05 09:25 +0800
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-05 09:25 +0800
1from ninja import Router
2from typing import List, Optional
3from uuid import UUID
4from core.auth import JWTAuth
5from core.permissions import require_tenant
6from core.exceptions import NotFoundException
7from .schemas import (
8 AccountIn, AccountOut,
9 ProductIn, ProductOut,
10 StockMoveIn, StockMoveOut,
11 InvoiceIn, InvoiceOut, InvoiceLineOut, InvoicePaymentIn,
12 ShipmentIn, ShipmentOut, ShipmentUpdateIn
13)
14from .models import Account, Product, StockMove, Invoice, Warehouse, Shipment
15from .services import ERPService
17router = Router(tags=["ERP & Accounting"])
19def shipment_out(shipment):
20 return ShipmentOut.model_validate(shipment)
23@router.get("/shipments", auth=JWTAuth(), response={200: List[ShipmentOut]})
24def list_shipments(request, status: Optional[str] = None):
25 tenant_id = require_tenant(request)
26 qs = Shipment.objects.filter(tenant_id=tenant_id)
27 if status:
28 qs = qs.filter(status=status)
29 return [shipment_out(shipment) for shipment in qs]
32@router.post("/shipments", auth=JWTAuth(), response={201: ShipmentOut})
33def create_shipment(request, payload: ShipmentIn):
34 tenant_id = require_tenant(request)
35 shipment = ERPService.create_shipment(tenant_id, payload)
36 return 201, shipment_out(shipment)
39@router.get("/shipments/{shipment_id}", auth=JWTAuth(), response={200: ShipmentOut})
40def get_shipment(request, shipment_id: UUID):
41 tenant_id = require_tenant(request)
42 shipment = Shipment.objects.filter(id=shipment_id, tenant_id=tenant_id).first()
43 if not shipment:
44 raise NotFoundException("Shipment not found.")
45 return shipment_out(shipment)
48@router.patch("/shipments/{shipment_id}", auth=JWTAuth(), response={200: ShipmentOut})
49def update_shipment(request, shipment_id: UUID, payload: ShipmentUpdateIn):
50 tenant_id = require_tenant(request)
51 shipment = Shipment.objects.filter(id=shipment_id, tenant_id=tenant_id).first()
52 if not shipment:
53 raise NotFoundException("Shipment not found.")
54 for field, value in payload.dict(exclude_unset=True).items():
55 setattr(shipment, field, value)
56 shipment.save()
57 return shipment_out(shipment)
60@router.delete("/shipments/{shipment_id}", auth=JWTAuth(), response={204: None})
61def delete_shipment(request, shipment_id: UUID):
62 tenant_id = require_tenant(request)
63 deleted, _ = Shipment.objects.filter(id=shipment_id, tenant_id=tenant_id).delete()
64 if not deleted:
65 raise NotFoundException("Shipment not found.")
66 return 204, None
68# ── Chart of Accounts ─────────────────────────────────────────────────────────
69@router.get("/accounts", auth=JWTAuth(), response={200: List[AccountOut]})
70def list_accounts(request):
71 tenant_id = require_tenant(request)
72 ERPService.get_or_create_chart_of_accounts(tenant_id)
73 accounts = Account.objects.filter(tenant_id=tenant_id)
74 return list(accounts)
76@router.post("/accounts", auth=JWTAuth(), response={201: AccountOut})
77def create_account(request, payload: AccountIn):
78 tenant_id = require_tenant(request)
79 acct = Account.objects.create(
80 tenant_id=tenant_id,
81 **payload.dict()
82 )
83 return 201, acct
85# ── Products & Inventory ──────────────────────────────────────────────────────
86@router.get("/products", auth=JWTAuth(), response={200: List[ProductOut]})
87def list_products(request, search: Optional[str] = None):
88 tenant_id = require_tenant(request)
89 qs = Product.objects.filter(tenant_id=tenant_id)
90 if search:
91 qs = qs.filter(name__icontains=search) | qs.filter(sku__icontains=search)
93 result = []
94 for p in qs:
95 result.append(ProductOut(
96 id=p.id,
97 sku=p.sku,
98 name=p.name,
99 description=p.description,
100 category=p.category,
101 cost_price=p.cost_price,
102 selling_price=p.selling_price,
103 tax_rate=p.tax_rate,
104 unit_of_measure=p.unit_of_measure,
105 is_active=p.is_active,
106 current_stock=p.current_stock,
107 reorder_level=p.reorder_level,
108 created_at=p.created_at,
109 updated_at=p.updated_at,
110 ))
111 return result
113@router.post("/products", auth=JWTAuth(), response={201: ProductOut})
114def create_product(request, payload: ProductIn):
115 tenant_id = require_tenant(request)
116 p = Product.objects.create(
117 tenant_id=tenant_id,
118 **payload.dict()
119 )
120 return 201, ProductOut(
121 id=p.id,
122 sku=p.sku,
123 name=p.name,
124 description=p.description,
125 category=p.category,
126 cost_price=p.cost_price,
127 selling_price=p.selling_price,
128 tax_rate=p.tax_rate,
129 unit_of_measure=p.unit_of_measure,
130 is_active=p.is_active,
131 current_stock=p.current_stock,
132 reorder_level=p.reorder_level,
133 created_at=p.created_at,
134 updated_at=p.updated_at,
135 )
137# ── Stock Moves ───────────────────────────────────────────────────────────────
138@router.get("/stock-moves", auth=JWTAuth(), response={200: List[StockMoveOut]})
139def list_stock_moves(request, product_id: Optional[UUID] = None):
140 tenant_id = require_tenant(request)
141 qs = StockMove.objects.filter(tenant_id=tenant_id).select_related('product', 'source_warehouse', 'dest_warehouse')
142 if product_id:
143 qs = qs.filter(product_id=product_id)
145 result = []
146 for m in qs:
147 result.append(StockMoveOut(
148 id=m.id,
149 product_id=m.product.id,
150 product_name=m.product.name,
151 product_sku=m.product.sku,
152 source_warehouse_name=m.source_warehouse.name if m.source_warehouse else None,
153 dest_warehouse_name=m.dest_warehouse.name if m.dest_warehouse else None,
154 quantity=m.quantity,
155 unit_cost=m.unit_cost,
156 move_type=m.move_type,
157 reference_doc=m.reference_doc,
158 created_at=m.created_at,
159 ))
160 return result
162@router.post("/stock-moves", auth=JWTAuth(), response={201: StockMoveOut})
163def create_stock_move(request, payload: StockMoveIn):
164 tenant_id = require_tenant(request)
165 product = Product.objects.filter(id=payload.product_id, tenant_id=tenant_id).first()
166 if not product:
167 raise NotFoundException("Product not found.")
169 src_wh = Warehouse.objects.filter(id=payload.source_warehouse_id, tenant_id=tenant_id).first() if payload.source_warehouse_id else None
170 dst_wh = Warehouse.objects.filter(id=payload.dest_warehouse_id, tenant_id=tenant_id).first() if payload.dest_warehouse_id else None
172 m = StockMove.objects.create(
173 tenant_id=tenant_id,
174 product=product,
175 source_warehouse=src_wh,
176 dest_warehouse=dst_wh,
177 quantity=payload.quantity,
178 unit_cost=payload.unit_cost,
179 move_type=payload.move_type,
180 reference_doc=payload.reference_doc,
181 )
182 return 201, StockMoveOut(
183 id=m.id,
184 product_id=m.product.id,
185 product_name=m.product.name,
186 product_sku=m.product.sku,
187 source_warehouse_name=m.source_warehouse.name if m.source_warehouse else None,
188 dest_warehouse_name=m.dest_warehouse.name if m.dest_warehouse else None,
189 quantity=m.quantity,
190 unit_cost=m.unit_cost,
191 move_type=m.move_type,
192 reference_doc=m.reference_doc,
193 created_at=m.created_at,
194 )
196# ── Invoices ──────────────────────────────────────────────────────────────────
197@router.get("/invoices", auth=JWTAuth(), response={200: List[InvoiceOut]})
198def list_invoices(request, status: Optional[str] = None):
199 tenant_id = require_tenant(request)
200 qs = Invoice.objects.filter(tenant_id=tenant_id).prefetch_related('lines')
201 if status:
202 qs = qs.filter(status=status)
204 result = []
205 for inv in qs:
206 result.append(InvoiceOut(
207 id=inv.id,
208 invoice_number=inv.invoice_number,
209 customer_name=inv.customer_name,
210 customer_email=inv.customer_email,
211 deal_id=inv.deal_id,
212 issue_date=inv.issue_date,
213 due_date=inv.due_date,
214 status=inv.status,
215 currency=inv.currency,
216 subtotal=inv.subtotal,
217 tax_total=inv.tax_total,
218 total_amount=inv.total_amount,
219 amount_paid=inv.amount_paid,
220 balance_due=inv.balance_due,
221 notes=inv.notes,
222 terms=inv.terms,
223 lines=[
224 InvoiceLineOut(
225 id=l.id,
226 product_id=l.product_id,
227 description=l.description,
228 quantity=l.quantity,
229 unit_price=l.unit_price,
230 tax_rate=l.tax_rate,
231 subtotal=l.subtotal,
232 tax_amount=l.tax_amount,
233 total=l.total,
234 ) for l in inv.lines.all()
235 ],
236 created_at=inv.created_at,
237 updated_at=inv.updated_at,
238 ))
239 return result
241@router.post("/invoices", auth=JWTAuth(), response={201: InvoiceOut})
242def create_invoice(request, payload: InvoiceIn):
243 tenant_id = require_tenant(request)
244 inv = ERPService.create_invoice(tenant_id, payload)
245 return 201, InvoiceOut(
246 id=inv.id,
247 invoice_number=inv.invoice_number,
248 customer_name=inv.customer_name,
249 customer_email=inv.customer_email,
250 deal_id=inv.deal_id,
251 issue_date=inv.issue_date,
252 due_date=inv.due_date,
253 status=inv.status,
254 currency=inv.currency,
255 subtotal=inv.subtotal,
256 tax_total=inv.tax_total,
257 total_amount=inv.total_amount,
258 amount_paid=inv.amount_paid,
259 balance_due=inv.balance_due,
260 notes=inv.notes,
261 terms=inv.terms,
262 lines=[
263 InvoiceLineOut(
264 id=l.id,
265 product_id=l.product_id,
266 description=l.description,
267 quantity=l.quantity,
268 unit_price=l.unit_price,
269 tax_rate=l.tax_rate,
270 subtotal=l.subtotal,
271 tax_amount=l.tax_amount,
272 total=l.total,
273 ) for l in inv.lines.all()
274 ],
275 created_at=inv.created_at,
276 updated_at=inv.updated_at,
277 )
279@router.get("/invoices/{invoice_id}", auth=JWTAuth(), response={200: InvoiceOut})
280def get_invoice(request, invoice_id: UUID):
281 tenant_id = require_tenant(request)
282 inv = Invoice.objects.filter(id=invoice_id, tenant_id=tenant_id).prefetch_related('lines').first()
283 if not inv:
284 raise NotFoundException("Invoice not found.")
285 return InvoiceOut(
286 id=inv.id,
287 invoice_number=inv.invoice_number,
288 customer_name=inv.customer_name,
289 customer_email=inv.customer_email,
290 deal_id=inv.deal_id,
291 issue_date=inv.issue_date,
292 due_date=inv.due_date,
293 status=inv.status,
294 currency=inv.currency,
295 subtotal=inv.subtotal,
296 tax_total=inv.tax_total,
297 total_amount=inv.total_amount,
298 amount_paid=inv.amount_paid,
299 balance_due=inv.balance_due,
300 notes=inv.notes,
301 terms=inv.terms,
302 lines=[
303 InvoiceLineOut(
304 id=l.id,
305 product_id=l.product_id,
306 description=l.description,
307 quantity=l.quantity,
308 unit_price=l.unit_price,
309 tax_rate=l.tax_rate,
310 subtotal=l.subtotal,
311 tax_amount=l.tax_amount,
312 total=l.total,
313 ) for l in inv.lines.all()
314 ],
315 created_at=inv.created_at,
316 updated_at=inv.updated_at,
317 )
319@router.post("/invoices/{invoice_id}/pay", auth=JWTAuth(), response={200: InvoiceOut})
320def record_payment(request, invoice_id: UUID, payload: InvoicePaymentIn):
321 tenant_id = require_tenant(request)
322 inv = ERPService.record_payment(tenant_id, invoice_id, payload.amount)
323 return get_invoice(request, inv.id)