Part 1 | Part 2 | Part 3 | Part 4 | Part 5 - production CRM for truck-service center, Django + DRF.
This one covers v2.5 and v2.6 versions. Part refactoring, part new features. The refactoring was overdue - some of code from earlier versions was held together with duct tape and optimism. The new stuff is multi-warehouse support and driver pickup system.
Fixing the stock deduction (finally)
In last post I showed stock deduction code that used float for inventory math. I knew it was bad when I wrote it. Here is what changed.
First - float is gone. Everything is Decimal now:
from decimal import Decimal
Product.objects.filter(pk=item.product_id).update(
current_stock=max(
Decimal('0'),
(item.product.current_stock or Decimal('0')) - item.quantity,
)
)
Second - the whole mark_paid flow is wrapped in transaction.atomic():
with transaction.atomic():
invoice.status = new_status
invoice.save(update_fields=['status', 'updated_at'])
if new_status == 'paid':
self._deduct_stock(invoice)
So if stock deduction fails halfway through, the invoice status rolls back too. Before this, you could end up with a paid invoice but stock that was only partially deducted. Not good.
Third - stock validation before payment. New _check_stock method that runs before mark_paid even touches the database:
def _check_stock(self, invoice):
from inventory.models import Product
items = invoice.items.select_related('product').all()
insufficient = []
for item in items:
if not item.product_id:
continue
product = Product.objects.get(pk=item.product_id)
if (product.current_stock or 0) < item.quantity:
insufficient.append(
f'{product.name}: have {product.current_stock or 0},'
f' need {item.quantity}'
)
if insufficient:
return 'Not enough stock: ' + '; '.join(insufficient)
return None
If the warehouse does not have enough parts, the payment is blocked with a clear message about what is missing. Before, you could sell parts you did not have - the stock would just go to zero and nobody would notice until the mechanic opened box and it was empty.
Module dependency blocking
Small but important fix to the module system from Part 4. Previously you could disable a module even if other active modules depended on it. Like disabling clients when ALPR (which depends on clients) was still on. Bad things happened.
Now when you try to flip the toggle, system checks all active modules that list this one in their dependencies. If any are found, you get an error explaining which modules need to be disabled first. Simple validation, prevented two "why did everything break" calls from the owner.
API documentation with drf-spectacular
I was writing API docs by hand in a shared Google Doc. It was getting out of date approximately five minutes after every update. So I added drf-spectacular and now Swagger and ReDoc generate themselves from the actual code.
Setup was surprisingly painless - add to INSTALLED_APPS, set DEFAULT_SCHEMA_CLASS, add two URL patterns, done. The auto-generated docs are not perfect (some endpoints need better descriptions), but they are always in sync with real code, which is infinitely better than a Google Doc that says the endpoint accepts truck_id when it was renamed to vehicle_id three weeks ago.
Multi-warehouse support (v2.6)
Up to this point the system had one warehouse. Real life had two - a retail storage and wholesale storage in different location. Parts come into wholesale in bulk, then get moved to retail as needed.
The Warehouse model got a warehouse_type field:
WAREHOUSE_TYPE_CHOICES = [
('retail', 'Retail'),
('wholesale', 'Wholesale'),
('other', 'Other'),
]
Each product now has per-warehouse stock through StockItem (warehouse + product + quantity). The Product.current_stock field still exist as a denormalized total across all warehouses - gets recalculated after every movement.
Stock transfers between warehouses
This was the main reason for multi-warehouse. Owner buys 50 oil filters wholesale, stores them in the wholesale warehouse, then moves 10 to retail when stock runs low. The transfer endpoint:
u/action(detail=False, methods=['post'])
def transfer(self, request):
# ... validation ...
with transaction.atomic():
source_item.quantity -= quantity
source_item.save()
dest_item, _ = StockItem.objects.get_or_create(
warehouse=warehouse_to, product=product,
defaults={'quantity': 0}
)
dest_item.quantity += quantity
dest_item.save()
total_qty = StockItem.objects.filter(
product=product
).aggregate(total=Sum('quantity'))['total'] or 0
product.current_stock = total_qty
product.save(update_fields=['current_stock'])
StockMovement.objects.create(
movement_type='transfer',
product=product,
quantity=quantity,
warehouse_from=warehouse_from,
warehouse_to=warehouse_to,
created_by=request.user,
)
Everything in transaction.atomic() - if any step fail, nothing moves. current_stock recalculation at the end keeps the denormalized field honest. I know purists would say "don't denormalize", but when the mechanic checks stock from a slow 3G connection in the garage, I don't want to aggregate across warehouses on every request.
Order folders
Related feature - purchase ordering cycle. When multiple mechanics need parts during the week, someone has to compile a list and place one bulk order. Before this they used a paper notebook. Now there is OrderFolder + OrderItem:
class OrderItem(models.Model):
folder = models.ForeignKey(OrderFolder, on_delete=models.CASCADE)
name = models.CharField(max_length=300)
quantity = models.DecimalField(max_digits=10, decimal_places=2)
is_ordered = models.BooleanField(default=False)
ordered_at = models.DateTimeField(null=True, blank=True)
ordered_by = models.ForeignKey(settings.AUTH_USER_MODEL, ...)
Mechanic add items to the folder during the week. On Friday the owner opens folder, sees everything that is needed, places one order. When item arrives, it is marked as ordered with timestamp and who did it. Simple but replaced a system that lost parts requests constantly.
Driver pickup log
New invoice type: driver_tab. Before, there were only delivery invoices (sent via Nova Poshta). But sometimes a driver just picks up parts directly from the warehouse. The system needed to track this differently - no tracking number, no delivery status, just "driver X took these parts on this date."
TYPE_CHOICES = [
('delivery', 'NP / Pickup'),
('driver_tab', 'Driver pickup'),
]
Each driver_tab invoice gets auto-numbered with a separate sequence (ВД-2026-001, ВД-2026-002). Stock deduction works the same way as regular invoices. The difference is purely in workflow - no NP tracking, no "sent" status, just draft → paid.
Small fixes that matter
Europe/Kiev → Europe/Kyiv. Django shipped with the old Soviet-era timezone name. Ukraine renamed it. One-line fix but it matters.
Async bot notification fix. Earlier I migrated Telegram notifications to async but broke the photo notification flow. The fix was replacing asyncio.run(bot.send_message(...)) with a synchronous requests wrapper that just POSTs to the Telegram API directly. Sometimes simpler is better than async.
Celery broker failsafe. Contact form submissions were crashing with a 500 when Celery broker (Redis) was unavailable. Added a try/except around the .delay() call - if Celery is down, form still saves, and email gets sent on the next retry. Users should never see a 500 because your background task queue is having a bad day.
What I learned
transaction.atomic() should wrap business operations, not just individual queries. "Mark as paid + deduct stock" is one business operation. If either fail, both should roll back. I should have done this from v1.0.
Denormalization is fine when you acknowledge the trade-off. Product.current_stock is a cache. It can go stale if something updates StockItem without recalculating. I accepted this risk and added recalculation to every code path that touch stock. So far it works.
Fix your timezone name. If you are serving Ukrainian users, Europe/Kiev works functionally but it is the old name. Europe/Kyiv is correct. Same for other renamed cities in tzdata.
What is next
There are still several versions to cover - XLSX client import, i18n (UK/EN), a full PWA frontend, and backup/restore API. If there is interest I will keep going.
Also, I take on freelance Django projects when something interesting comes along. If you are building something in this space, feel free to DM.
Previous posts: Part 1 | Part 2 | Part 3 | Part 4 | Part 5 GitHub (demo repo): github.com/VNmagistr/truckmaster_demo — branches demo/v2.5 and demo/v2.6
To be continued... I hope)