Skip to content
BUI CHI DUNG
  • AboutSections
  • ExpertiseSections
  • ProjectsSections
  • PrinciplesSections
  • AI EngineeringSections
  • ExperienceSections
  • ContactSections
  • E-Budget case studyCase studies
  • ESG System case studyCase studies
  • AI Sales Forecasting case studyCase studies
  • Digisales case studyCase studies
  • Salesportal case studyCase studies
  • Download CVLinks
  • Send an emailLinks
  • LinkedInLinks

15 results

Back to projects

Case study

AI Sales Forecasting

Forecast Review and Approval Platform

A web platform where a sales organization reviews AI-generated sales forecasts, adjusts them cell by cell, sends the changes for approval, and watches how close the forecasts come to actual sales.

Excel Technologies

Project scale · counted from the repository, 2026-10-13

Backend lines
~22.7k
JavaScript, excluding migrations
Frontend lines
~26k
TypeScript and TSX
Route modules
30
Mounted in the API
Permission keys
16
5 roles
Migration files
46
6 registered in the data source
Backend test blocks
84
19 files; no frontend tests
Languages
2
Vietnamese and English
Commits
321
2026-06-01 to 2026-10-12

Problem and goals

Model-generated forecasts have to be reviewed and corrected by the sales organization before they are used. Each person works on their own part, changes need approval, and the month has to close on a schedule.

  • Review the model's forecast

    Sales managers see the model volume next to actual sales, by month, customer and product, for their own scope only.

  • Adjust and approve

    A manager adjusts a forecast cell and submits it; an approver accepts or rejects it with a reason.

  • Close the month on a schedule

    A monthly calendar creates, reminds and locks the forecast so that editing stops on a known day.

  • Watch forecast accuracy

    Forecasts are compared with actual sales over recent months, with warnings when errors persist.

System architecture

Request path

  1. React app

    A single-page app with TanStack Router, Query, Table and Virtual, a global store and Recharts.

  2. Express API

    Authentication, then permission or role checks, then route modules for forecasts, adjustments, locks, master data and monitoring.

  3. Services

    Business logic: the forecast query, the adjustment workflow, lock rules and accuracy calculation.

  4. PostgreSQL

    Application data through a raw driver pool; TypeORM only for app users and notifications.

Supporting services

  • Data warehouse

    A read-only Redshift connection supplies forecasts, actual sales and master data.

  • Scheduled work

    A daily sync, a monthly sync and a lock calendar run inside the API process.

  • Caches

    A per-user response cache, a stale-while-revalidate name cache and a permission cache.

  • Notifications

    In-app messages and an email queue with retry.

  • Excel export

    The forecast grid is exported as a workbook with ExcelJS.

  • Microsoft Entra SSO

    Sign-in through the authorization-code flow with PKCE.

  • Cluster mode

    pm2 runs several API processes; cache changes are announced through PostgreSQL notifications.

Domain modules A Node.js API with 30 mounted route modules and a React single-page app.

Simplified from the repository. It shows layers and supporting capabilities, not a deployment topology.

Engineering deep dives

From model output to a reviewable forecast

How forecasts and actual sales get into the application and what one row holds.

Documented design

  • A read-only connection to a data warehouse supplies the model's forecasts, actual sales and master data. The repository holds no model training or inference code.
  • One forecast table keeps three volumes per row: the model volume, the adjusted volume and the final volume, plus the workflow columns (flag, submitter, rejection reason). The adjustment paths do not change the model volume; reverting an adjustment copies it back.
  • A row is identified by the forecast month, the target month, the customer, the product and a lead time of one to six months.
  • A daily job at 07:00 reads the trigger months flagged in a warehouse event table. For each month not yet marked as synced it loads the forecast in chunks of 5,000 rows and then marks the month. A month that fails stays pending and is retried the next day. The same run replaces the previous day's month of actual sales.
  • A monthly job on the first day syncs employees and product colors. An admin route can reload the whole forecast table in one transaction, which also replaces the adjustments.

Design rationale

  • Keeping the model volume untouched beside the adjusted and final volumes means a review can always be compared with, or reverted to, what the model produced.
  • Marking a month as synced only after it loads makes a failed day retry on its own.
From the warehouse to a final volume
  1. Data warehouse

    Model forecasts, actual sales, master data

  2. Daily sync

    Trigger months, 5,000-row chunks

  3. Forecast table

    Model, adjusted and final volume per row

  4. Review and approval

    Cell-level workflow

What identifies a forecast rowAbstract names for the columns of the forecast table.

Forecast monthTarget monthCustomerProductLead time (1 to 6 months)Sales group and office

Serving the forecast grid

One query for history and future, and caches that keep it quick.

Documented design

  • The grid endpoint builds one SQL statement with common table expressions: actual sales, the history forecast (lead time one), the future forecast (the snapshot of the target month) and a full outer join, so months that have only a forecast or only actuals still appear.
  • The window starts in January of the target year (or three months back when the target is before March) and ends six months after the target month.
  • A per-user response cache is keyed by role, identity, scope and normalized filters. The time to live defaults to 300 seconds and the cap to 100,000 cached rows; a larger result is not cached. Lock status is computed live and never cached.
  • A change to the data clears the caches in every API process through a PostgreSQL LISTEN and NOTIFY channel. Name lookups use a stale-while-revalidate cache, and the forecast table has five indexes, one of them partial.

Design rationale

  • With several API processes, an in-memory cache needs a way to be cleared everywhere; a database notification avoids adding another service.
One request
  1. Cache lookup

    Per user, scope and filters

  2. One SQL with CTEs

    Actuals, history and future forecast

  3. Names and fluctuation

    Looked up and attached

  4. JSON response

    All rows of the window

Cache defaultsDefaults read from the configuration code; they can be changed or switched off.
Time to live
300 s
Row cap
100,000
Forecast table indexes
5

Adjust, submit and approve

A cell-level workflow with a transaction around each step.

Documented design

  • A forecast cell moves from no flag to SUBMITTED and then to APPROVED or REJECTED. The adjustment is stored on the forecast row itself; approval copies the adjusted volume into the final volume, and a rejection requires a reason and keeps the adjusted volume.
  • Submit validates that rows are present, that each volume is a finite number that is not negative and that the lead time is an integer. The adjusted total of a cell is spread across the customer rows in proportion to their model volume, and the last row absorbs the rounding remainder.
  • Submit runs in one transaction and retries up to three times on a deadlock or serialization error. A sales manager cannot resubmit rows that are already submitted or approved.
  • Approve is one UPDATE on rows that are still SUBMITTED, so a repeated approval changes nothing. Approve-all does the same set-based update for every submitted row after the forecast month, with optional filters. Deleting a submission restores the model volume.
  • After the response, audit entries and notifications are written on a best-effort basis, and the lock state of the manager is updated.
  • Drafts are stored per user and month with one atomic upsert-and-delete statement, and the browser syncs them after one second of idle time, at most ten seconds after the first change.
  • A transfer request moves forecast rows between sales offices. It is created in one transaction, and its approval, by a line manager or a full-access role, rewrites the office on the stored rows in one transaction.

Design rationale

  • Updating only rows that are still SUBMITTED makes approving twice, or approving something already rejected, harmless without an idempotency key.
  • Spreading a cell's total in proportion to model volume keeps the shape of the forecast across customers.
Illustrative submit and approve flowSimplified sequence for one cell; it is not a trace of the real system.
  1. 1Sales manager to API: Submit an adjusted volume for a cell

  2. 2API to PostgreSQL: One transaction: spread across customers, flag SUBMITTED

  3. 3API to Sales manager: Submitted

  4. 4Approver to API: Approve

  5. 5API to PostgreSQL: Update only rows still SUBMITTED: final = adjusted

Decision on a submitted cell
Submitted cell

Approver decision

  • Yes

    APPROVED

    Final volume takes the adjusted volume

  • No

    REJECTED

    A reason is required; the adjusted volume is kept

Editing a hierarchy of totals

How an edit on a leaf or on a parent row keeps the totals consistent in the browser.

Documented design

  • Rows form a hierarchy. Each shows three series (model, adjusted and actual) and the adjusted one is editable. The grid is virtualized with TanStack Virtual and supports expandable rows and pinned columns.
  • Editing a leaf stores a draft on that leaf only, and every parent is recomputed as the exact sum of its children.
  • Editing a parent splits the rounded target over its children for that month, recursively. Weights are the children's model volumes, or their actual sales when the model volumes sum to zero; with no weight at all, the split is equal. Each share is rounded down, and the leftover units go one by one to the children with the largest fractional parts, ties by order. The shares always sum to the rounded target.
  • A parent edit spreads only over the children that are visible after filtering. Cells at or before the forecast month, in a locked period, or already submitted are not editable, and negative values are rejected in the interface.

Design rationale

  • Rounding down and handing out the leftover by largest remainder keeps every share a whole number without drifting from the target.
  • Storing a draft only on leaves means a parent never holds a value that disagrees with its children.
Splitting a targetIllustrative sketch of the largest-remainder rule with generic names, not the project's code.
function split(target, weights) {  const total = Math.round(target);  const sum = weights.reduce((a, w) => a + w, 0);  const exact = weights.map((w) => (total * w) / sum);  const shares = exact.map(Math.floor);  let left = total - shares.reduce((a, s) => a + s, 0);  const order = exact    .map((e, i) => [e - shares[i], i])    .sort((a, b) => b[0] - a[0]);  for (const [, i] of order) if (left-- > 0) shares[i] += 1;  return shares;}
The two directions
  1. Edit a leaf

    Parents become the sum of their children

  2. Edit a parent

    The target is split over visible children

Forecast accuracy monitoring

Comparing forecasts with actual sales and raising a warning when errors persist.

Documented design

  • For each sales manager, product group and lead time from one to six months, the service compares the sum of the final volume with the sum of actual sales over the last six completed months.
  • The error is a signed percentage, (forecast minus actual) divided by actual and multiplied by 100, and is skipped when actual sales are zero or missing. The average is the plain mean of the signed values; despite the name used in the code, it is not an absolute error.
  • A streak counts the most recent months whose absolute error is above the bad threshold, skipping months without data, and a warning is raised when the streak reaches the configured number of months. Defaults are 15 for good, 25 for bad and 3 months when no configuration row exists.
  • Results are stored in a history table and refreshed at startup and after each daily sync. A separate comparison view shows, per month and lead time, the model volume, the adjusted volume and actual sales with a percentage difference for both.
From volumes to a warning
  1. Final volume and actual sales

    Per month, lead time and product group

  2. Signed percentage error

    Skipped when actual is zero

  3. Mean and bad streak

    Last six completed months

  4. Warning

    When the streak reaches the configured length

Defaults without a configuration rowValues read from the code.
Good threshold
15
Bad threshold
25
Months in a streak
3

The monthly lock calendar

Closing the month on a schedule, with rules for unlocking.

Documented design

  • Three scheduled jobs run with node-cron in the local business timezone: on day 2 the lock records for the next forecast month are generated, on day 24 a reminder goes to managers who have not submitted, and on day 25 every still-open record is locked automatically.
  • A lock has a status (open, partially locked or locked), a type and a scope, either a business unit or one sales manager. Only the active month and the previous month are valid periods.
  • A partial lock leaves only cells that are neither submitted nor approved editable. A manager is locked automatically once none of their rows is left to edit.
  • Unlocking an automatic lock needs an explicit force and a reason, is refused while submissions are pending, and can carry an expiry. An unlocked approved cell becomes editable again and is approved again afterwards.
  • Which managers appear under a business unit depends on their user record: active, with a line manager and an employee code, and the unit among their sales groups.
Each month
  1. Day 2, 00:00

    Lock records generated

  2. Day 24, 09:00

    Reminder to managers who have not submitted

  3. Day 25, 09:00

    Open records locked automatically

Lock statusStatus values used for a business unit or a sales manager.

OPENPARTIAL_LOCKLOCKED

Access, audit and notifications

Who can sign in, what they may do, which rows they see, and how changes are recorded.

Documented design

  • Sign-in uses Microsoft Entra SSO through the authorization-code flow with PKCE. The state is an HMAC-signed payload with a short expiry, and the user must exist and be active in the application's own user table, which supplies the role and the scope.
  • There are 5 roles and 16 permission keys. The defaults live in code; an editable matrix is stored in the database, kept in a process cache, and changed only by a system admin. The token carries the permission list and a version number: changing the matrix raises the version, so tokens issued earlier are rejected and the user signs in again.
  • Data is scoped by services from the signed-in identity: a sales manager sees the customers mapped to them, and an executive sees the customers of the sales managers they are in charge of. Admin roles are scoped by division or sales group where the user record defines one.
  • The audit log records about 40 action types across 18 modules, with before and after values and the client address. Rows are only inserted by the application, and there is no update or delete route.
  • Notifications are in-app messages and email. Email goes through a queue with retries after 1, 5 and 15 minutes before it is marked failed, and a notification failure never fails the request.
  • The interface and the API messages are available in Vietnamese and English; the language travels in a request header, and message keys are typed so that a missing key is a compile error.

Design rationale

  • Putting a permission version in the token lets a matrix change take effect on the next request without a lookup of the matrix on every call.
  • Typed message keys catch translation gaps when the code is built, not when a user finds them.
Sign-in
  1. Microsoft Entra

    Authorization code with PKCE

  2. App user lookup

    By email; must be active

  3. Role and scope

    From the user record

  4. Token

    Permissions and a permission version

RolesThe five roles declared in the code.

SALES_MANAGEREXECUTIVEBU_ADMINBUSINESS_ADMINSYSTEM_ADMIN

Engineering trade-offs

  • Model, adjusted and final volume in one row

    Why it matters
    Reads and the grid need no join, and the model volume stays beside what was changed.
    What it costs
    There is no history of earlier approved values: unlocking and approving again overwrites in place, and a full reload replaces the adjustments.
  • In-memory caches cleared by a database notification

    Why it matters
    It speeds up the heaviest query without adding another service.
    What it costs
    Every API process holds its own copy, and a cached response can be up to its time to live out of date.
  • Splitting a parent edit in the browser

    Why it matters
    Editing a large hierarchy stays interactive and totals are consistent before anything is sent to the server.
    What it costs
    The rule lives in the client, covers only the rows visible after filtering, and has no automated test.
  • A raw driver for most data and TypeORM for a few entities

    Why it matters
    The project's decision log records that moving everything to an ORM was too risky for the timeline.
    What it costs
    Two ways of reaching the same database have to be understood and kept consistent.

Lessons learned

  1. Keep the model's output beside the human's changes

    An untouched model volume makes every adjustment reviewable and reversible, which is what an approval workflow needs.

  2. Make a recurring sync retry itself

    Marking a unit of work as done only after it loads lets a failed day heal on the next run without manual repair.

  3. Check the documents against the code

    The repository's own documents had drifted from the code in several places, so every claim on this page was taken from the code.

Known limitations

  • The reallocation rule runs in the browser and has no automated test. The frontend has no tests at all; the backend suite covers locks, rejection, notifications and translations, and 8 of its 19 files need a local PostgreSQL.
  • A low-forecast monitor page exists only as a mock-up with sample data; no backend computes it.
  • The daily forecast sync inserts chunks without a surrounding transaction, so a failure part-way through a month leaves it partly loaded until the retry.
  • The grid endpoint returns every row of the window in one response, and the Excel export builds the whole workbook in memory with no row cap.

Tech stack

  • Backend

    • Node.js 20
    • Express 4
    • node-cron
    • nodemailer
    • ExcelJS
  • Data

    • PostgreSQL 16
    • TypeORM
    • Redshift (read-only)
  • Authentication

    • Microsoft Entra SSO
    • MSAL
  • Frontend

    • React 19
    • TypeScript
    • Vite
    • TanStack Router
    • TanStack Query
    • TanStack Table
    • TanStack Virtual
    • TanStack Store
    • Recharts
    • Tailwind CSS 3
  • Operations

    • Docker
    • nginx
    • pm2
    • GitHub Actions