跳到正文
原文
Google AI:DEV 作者专属(RSS)· Mahmoud Farouk·· 4 小时前AI 评分30

用 Spring Boot 和 Angular 构建多租户 POS 与库存系统的三个设计决策

Building a multi-tenant POS and inventory system with Spring Boot and Angular: three design decisions that paid off

AI 导读

开发者用 Spring Boot 4(Java 25)、Angular 21 和 PostgreSQL 构建了一套自托管的多租户 POS、库存与采购系统,运行于 Docker,支持英语、阿拉伯语(从右到左)和法语。

正文

Small shops need three things from software: sell quickly, know what is in stock and trust the numbers. I built a self-hosted inventory, point-of-sale and purchasing system for them, with Spring Boot 4 (Java 25), Angular 21 and PostgreSQL, running in Docker. It works in English, Arabic (right-to-left) and French, and every shop is an isolated tenant.

This post is not a feature tour. It covers three design decisions I would make again, with the real code behind each.

The shop dashboard: sales today, low stock and stock value

1. Tenant isolation that nobody can forget to apply

In a multi-tenant system the worst bug is a missing WHERE tenant_id = ?. One forgotten filter in one repository method and a shop sees another shop's sales.

So I do not rely on developers remembering. A Hibernate filter is switched on by an aspect before every service method runs:

@Aspect
@Component
public class TenantAspect {

    @PersistenceContext
    private EntityManager entityManager;

    @Before("execution(* com.enterprise.starter.kit.modules..service..*(..))")
    public void enableTenantFilter() {
        String tenantId = TenantContext.getTenantId();
        if (tenantId == null) return;

        Session session = entityManager.unwrap(Session.class);
        session.enableFilter("tenantFilter").setParameter("tenantId", tenantId);
    }
}

The tenant id comes from the authenticated user's JWT and lives in a TenantContext. Entities that carry the filter definition are restricted automatically; global tables such as subscription plans are skipped.

What I like about this: the rule is enforced in one place, new modules inherit it for free, and a code reviewer only has to check that an entity declares the filter, not that every query remembers it.

The trade-off is that it is implicit. Anything that runs outside the service layer (scheduled jobs, raw SQL) does not pass through the aspect, so it needs its own care.

2. A stock ledger you never edit directly

The product table has a quantityOnHand column, and it is tempting to just update it. I did not. Every change goes through an append-only stock_movements table:

/**
 * Audit ledger of every stock change. Positive quantity = stock in,
 * negative quantity = stock out. Never written directly - only via
 * the catalog adjust-stock, purchase-order receive, and sale-create flows,
 * so it always stays consistent with CatalogItem#getQuantityOnHand().
 */
@Entity
@Table(name = "stock_movements")
public class StockMovement extends BaseEntity {
    // catalogItem, movementType, quantity (+in / -out),
    // referenceType + referenceId (which sale or purchase order caused it),
    // optional variant id, note, occurredAt
}

There are only three ways stock moves: a manual adjustment, receiving a purchase order and creating a sale. Each writes a movement that points back to the thing that caused it.

That gives the shop owner a per-item history ("why is this at 3?") and gives me a debugging tool: when a number looks wrong, the ledger says which sale or purchase order did it.

A per-item stock ledger: sales, purchases and adjustments

3. A checkout that keeps working when the Wi-Fi does not

Shops lose connectivity. A cashier should not have to stop selling. The Angular POS queues cash sales locally and replays them when the browser reports it is online again:

const STORAGE_KEY = 'pos_offline_sale_queue';
const FAILED_STORAGE_KEY = 'pos_offline_sale_failed';

constructor() {
  window.addEventListener('online', () => { this.isOnline.set(true); this.syncQueue(); });
  window.addEventListener('offline', () => this.isOnline.set(false));
}

A few rules keep this honest:

  • Only cash and "other" sales are queued. Card payments need a live round trip to create and confirm a payment intent, so they cannot be faked offline.
  • The receipt is a preview. The server allocates the real invoice numbers when the sale syncs, so the offline receipt is built from a stored snapshot (totals, tax, line items), not from numbers I invented on the client.
  • Failures are never swallowed. If the server rejects a queued sale, for example because stock ran out while offline, it moves to a "failed sales" list for the cashier to review.

Signals (isOnline, queue, syncing, failedSales) keep the UI simple: the template just reacts to connectivity, the queue length and the failed list.

Selling at the POS: tap products to fill the cart, then check out

A grounded assistant, without sending data anywhere

The same system has a chat assistant that answers questions such as "which items are low on stock?". It runs on a local model through Ollama, so shop data never goes to a third-party AI service.

Two choices make a small local model usable:

  1. Each question is sent with a fresh snapshot of that tenant's data, through the same tenant filter as every other screen.
  2. The snapshot starts with a summary and every product carries a precomputed OK or LOW status, so the model never has to compare numbers itself. The prompt tells it to answer only from the snapshot and to say so when the answer is not there.

The assistant is read-only: it cannot sell, adjust stock or place orders.

What I would tell my past self

  • Put the safety rule (tenant filter) where it cannot be skipped, not in a coding guideline.
  • Treat stock as a ledger, not a number.
  • Design the offline path around what cannot work offline, and be explicit about it.
  • Small local models need pre-digested data, not raw tables.

Try it or talk to me

The system is part of a larger Spring Boot and Angular SaaS starter I maintain (the Arab Enterprise Kit). I also take freelance full-stack, backend and architecture projects: mahmoud-farouk-portfolio.vercel.app.

What would you do differently for the offline queue? I am curious how others handle replay conflicts.

来源:Google AI:DEV 作者专属(RSS) · dev.to