OPQMS Community Edition v1.0
Offline Procurement & Quotation Management System
- C#
- .NET 10
- ASP.NET Core
- EF Core
- SQLite
- React
- TypeScript
- Vite
- QuestPDF
- ClosedXML
- Inno Setup
- Clean Architecture
Trade-off Analysis & Lessons Learned
What Worked
- ClosedXML validation pipelines caught malformed rows before a single write
- QuestPDF produced print-ready quotes without an external document service
- Automated migrations in ProgramData made upgrades recoverable
What Failed
- The first import design validated per-row inside the transaction, rolling back the whole file on one bad row
- Concurrent launches could open two instances against the same SQLite file
- Distributed sync between installations was deferred as over-engineering for v1
Architectural Iterations
- Per-row validation replaced by a two-phase validation-then-commit pipeline
- Single-instance mutex added after a dual-launch corruption scare
- Distributed sync dropped in v1 in favor of offline reliability and a simpler surface
Lessons Learned
- Validation before mutation makes Excel imports atomic
- A single-instance mutex prevents corrupt concurrent writes to the embedded database
- Offline-first means the database ships inside the product, not beside it
Overview
OPQMS Community Edition is an offline-first Windows desktop application for procurement and quotation management, built on C# / .NET 10 with an ASP.NET Core Web API core, EF Core 10, and SQLite. The desktop shell is a React 19 + TypeScript + Vite client; QuestPDF produces print-ready quotations, ClosedXML powers atomic Excel imports, and Inno Setup packages the installer.
It is proprietary and private: the product ships to customers, the source does not. The community edition runs entirely on the local machine — no cloud, no telemetry, no internet dependency.
Architecture
The system is a layered desktop application with a strict Clean Architecture boundary: the React client talks to a locally hosted ASP.NET Core Web API, which owns all persistence through EF Core 10 against an embedded SQLite database. There is no server, no queue, and no external service in the critical path — the diagram below is interactive, zoom with the buttons or the mouse wheel.
Offline-First by Design
The defining trade-off of OPQMS is reliability without connectivity. A procurement officer on a workshop floor, in a store room, or at a client site must never be blocked by a network drop. That constraint simplified the architecture rather than complicating it: a local embedded database, local auth, and local document generation mean the failure modes are the machine's, not the internet's.
V1 deliberately favors stability over distributed sync. Replicating quotations across offices would have added conflict resolution, transport security, and a server — none of which a single-machine workflow needs. Offline reliability is the invariant; sync is a deferred feature, not a regret.
Local RBAC Authentication
Access control is local and role-scoped. The app seeds an administrator on first launch, and every user account carries a role — administrator, approver, or viewer — enforced at the API boundary on every request:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Local issuer/audience — tokens are minted and validated in-process.
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(appKey)),
ValidIssuer = "opqms.local",
ValidAudience = "opqms.client",
};
});
Passwords are hashed with PBKDF2 and stored in the local database; the token lifecycle stays entirely inside the desktop process, so a locked-down machine with a revoked user account is a configuration, not a network call.
Atomic Excel Imports
Suppliers send price lists as Excel workbooks. Imports run a two-phase validation-then-commit pipeline: ClosedXML reads every row into an in-memory model, the validation phase rejects malformed rows before any mutation, and only a fully valid workbook reaches the database — inside one transaction:
var workbook = new XLWorkbook(stream);
var rows = ParseRows(workbook.Worksheet(1));
// Phase 1 — validate everything, collect every failure.
var failures = rows.Where(row => !row.IsValid(out var reason))
.Select(row => row.Error(reason))
.ToList();
// Phase 2 — commit only when the entire workbook is clean.
if (failures.Count == 0)
{
await using var tx = await db.Database.BeginTransactionAsync(ct);
await db.PriceLists.AddRangeAsync(rows.Select(r => r.ToEntity()), ct);
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
else
{
throw new ImportValidationException(failures); // nothing was written
}
The early design validated rows inside the transaction and rolled the whole file back on one bad row; the two-phase pipeline gives the user a complete error report instead of a mystery rollback.
Print-Ready PDF Quotations
Quotations are rendered to print-ready PDFs with QuestPDF — no external document service, no browser, no network. The quotation document is a declarative composition that mirrors the paper form the customer already trusts:
Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(48);
page.Header().Text($"Quotation {quote.Number}")
.FontSize(16).Bold();
page.Content().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn();
columns.RelativeColumn();
columns.ConstantColumn(90);
});
foreach (var line in quote.Lines)
{
table.Cell().Text(line.Description);
table.Cell().AlignRight().Text(line.Quantity.ToString());
table.Cell().AlignRight().Text(line.Total.Format("ZWL"));
}
});
});
}).GeneratePdf(path);
QuestPDF's deterministic layout engine means a quote looks identical on every machine — the document is generated locally, in milliseconds, with the customer's branding.
Embedded SQLite & Automated Migrations
Persistence is a single embedded SQLite database whose file lives under
ProgramData — outside the install directory, so upgrades never wipe user
data. EF Core 10 migrations are applied automatically on startup by
checking the schema version against the embedded migration history:
var pending = await db.Database
.GetMigrationsAsync(ct); // pending from history table
if (pending.Any())
{
await db.Database.MigrateAsync(ct); // idempotent, transactional
await db.Checkpoints.LogAsync("schema-upgraded", ct);
}
Because migrations are transactional and versioned in the database, a failed upgrade leaves the previous version intact and the next launch retries cleanly — upgradeability is a property of the data directory, not the installer.
Single-Instance Process Management
SQLite does not enjoy two writers. A named mutex guarantees one OPQMS instance per machine — a second launch activates the running window and exits instead of opening a second handle on the database:
using var mutex = new Mutex(true, @"Global\OPQMS.SingleInstance", out var createdNew);
if (!createdNew)
{
// A primary instance is already running — bring it to the foreground.
NativeMethods.ShowWindow(primaryWindow, ShowWindowCommand.Restore);
NativeMethods.SetForegroundWindow(primaryWindow);
return;
}
Application.Run(new MainWindow());
This is the cheapest corruption guard in the system: the dual-launch case that scared the team in early testing now cannot happen.
Status
Released at v1.0.0 as OPQMS Community Edition. Proprietary and private —
the product is distributed through the Inno Setup installer, and the
repository does not ship with it.