Phone repair shop inventory and ticket tracker
Django and PostgreSQL app for three repair shops covering per-shop parts stock, repair tickets, ready-text SMS, and daily revenue reports.
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add phone-repair-shop-inventory-and-ticket-tracker-4b2b3dCLAUDE.md# Repair shop tracker
Internal Django 5 and PostgreSQL app for a phone repair business with 3 shops. Users are shop staff (technician, front desk, manager) and one owner. It tracks per-shop parts stock, repair tickets, customer ready texts, and a daily revenue and parts-used report.
@REVIEW.md
## Commands
- `python manage.py runserver`
- `python manage.py test`
- `python manage.py check`
- `python manage.py makemigrations --check --dry-run`
- `ruff check .` and `ruff format .`
- `python manage.py daily_report --date YYYY-MM-DD`
- Seed data: the seed command in `shops` creates 3 shops and one user per role.
PostgreSQL must be running before `test` or `runserver`.
## Layout
- Apps: `shops`, `inventory`, `tickets`, `notifications`, `reports`.
- Business logic lives in each app's `services.py`, with type hints.
- Views stay thin: parse input, call a service, render.
## Stock and tickets
- Stock is tracked per (shop, part) in StockLevel.
- Change quantity only through StockMovement rows. Never edit `quantity` directly.
- Stock can never go below zero.
- Using a part on a ticket creates a negative StockMovement in the ticket's shop.
- Transfers between shops are one atomic operation that creates two linked movements.
- Ticket statuses: new, diagnosing, waiting_parts, repairing, ready, picked_up, cancelled.
- Status changes follow the allowed-transition table in `tickets/services.py`.
## Money
- Use Decimal and DecimalField. Never use float.
- Store all amounts in one currency. Currency and tax handling are open questions in SPEC.md.
## Access and scoping
- Staff queries are scoped to the user's shop. Only owners see all shops.
- Every view requires login.
- Every new view or query needs a test for shop scoping.
## SMS
- Send SMS only through `notifications/services.py`.
- The provider sits behind an interface. Tests and local dev use the fake backend.
- Send once, when a ticket first becomes ready. Respect `sms_opt_in`.
- Never send SMS in tests.
- Never log full customer phone numbers.
## Reports
- The `daily_report` command must be idempotent.
- Day boundaries use the shop's local timezone.
- Raw SQL is allowed only in reports, and each query needs a comment explaining it.
## Secrets
- Read secrets from environment variables: `DATABASE_URL` and `SMS_API_KEY`.
- Never commit `.env`. Never read or print it.
- Use YOUR_API_KEY placeholders in docs and examples.
## Conventions
- Django TestCase with factories.
- Commit migrations with the model changes that need them.
- Ruff handles formatting. Do not argue style in reviews.
## Workflow
- Explore, plan, code, then run the verify skill before saying done.
- Use `/clear` between unrelated tasks and `/compact` at natural breaks.
- Do not commit until asked.
.claude/agents/test-runner.md---
name: test-runner
description: Runs the Django test suite (or a given app or test path) and summarizes failures. Use it when test output would be large, so the main context only gets the failures.
model: haiku
tools: Bash, Read, Grep
maxTurns: 8
---
You run Django tests for the repair shop tracker and report results briefly.
Steps:
1. Run `python manage.py test` from the project root. If the caller gives an app (for example `inventory`) or a test path (for example `inventory.tests.test_services`), run only that target instead.
2. If the run is green, reply with `all passed` and the test count, nothing else.
3. If tests fail or error, return for each failure:
- the test name, as `module.Class.test_method`
- the key assertion or exception line
- the likely file and line, using Grep and Read only to confirm the location
4. Keep the total reply under 40 lines. Do not paste full tracebacks or unrelated log output.
Rules:
- Do not edit, create, or delete any file.
- Do not run migrations, `flush`, or `loaddata`.
- Do not print `.env` or any secret values.
- Do not suggest fixes beyond one line per failure, unless the caller asks.
.claude/settings.json{
"permissions": {
"allow": ["Bash(python manage.py test:*)", "Bash(python manage.py check:*)", "Bash(python manage.py makemigrations:*)", "Bash(python manage.py migrate:*)", "Bash(ruff check:*)", "Bash(ruff format:*)", "Bash(git status)", "Bash(git diff:*)"],
"ask": ["Bash(git push:*)", "Bash(pip install:*)", "Bash(python manage.py flush:*)", "Bash(python manage.py loaddata:*)"],
"deny": ["Read(./.env)", "Read(./.env.*)", "Bash(rm -rf:*)", "Bash(dropdb:*)", "Bash(psql:*DROP*)"],
"blockWritesOutsideWorkingDirectories": true
},
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "haiku"
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "f=$(python3 -c \"import sys,json; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))\" 2>/dev/null); case \"$f\" in *.py) command -v ruff >/dev/null 2>&1 && ruff format \"$f\" >/dev/null 2>&1;; esac; exit 0"
}
]
}
]
}
}
.claude/skills/stock-change/SKILL.md---
name: stock-change
description: Use for stock change in this project.
---
---
name: stock-change
description: Procedure for any change that touches stock so the ledger invariants hold: no direct quantity writes, no negative stock, and every change recorded as a movement.
when_to_use: Use for any work on parts, stock levels, transfers, receiving, adjustments, or ticket parts.
---
# Stock change
Stock is a ledger. `StockLevel.quantity` is a cached total. Every change to it must come with a `StockMovement` row, made in the same transaction.
## Rules
- Change stock only through functions in `inventory/services.py`.
- Never assign to `StockLevel.quantity` in a view, a model method, a form, a management command, or a test setup that is not a fixture. If no service function fits, add a new one.
- Never write `StockLevel.quantity` with `.update()` or raw SQL.
- Ticket parts go through the service function that uses parts. Do not adjust stock from the ticket view directly.
## Steps for any stock change
1. Open `transaction.atomic()`.
2. Get or create the `StockLevel` row for (shop, part).
3. Re-read that row with `select_for_update()` inside the transaction.
4. Compute the new quantity. If it would be below zero, raise `InsufficientStock` and change nothing.
5. Create the `StockMovement` with `shop`, `part`, `delta`, `reason`, `created_by`, and `ticket` when relevant.
6. Update `quantity` on the locked row.
Steps 4 to 6 should live in one helper, so every service function uses the same code path.
## Transfers
A transfer is one atomic operation that produces two linked movements: a negative delta in the source shop and a positive delta in the target shop.
- Lock both `StockLevel` rows in a fixed order (sort by primary key) to avoid deadlocks when two transfers cross.
- Check the source has enough stock before writing anything.
- Link the two movements with a shared reference. If the model has no field for this, add one in a migration before writing the transfer.
- If either side fails, neither movement is saved.
## Test checklist
Every change to stock needs tests that cover:
- [ ] Negative stock is rejected and the quantity and movement count do not change.
- [ ] The change is made through the locked, atomic pattern (a test calls the service function, not the model directly).
- [ ] A movement row exists with the correct delta, reason, and actor.
- [ ] Another shop's stock for the same part is unchanged.
- [ ] Transfers create exactly two linked movements, and a failed transfer creates none.
Use Django `TestCase` and the project's factories.
## Reference
See `examples.md` in this folder for a receive function, a transfer function, and matching tests.
.claude/skills/stock-change/examples.md# Stock change examples
Use these as the pattern. Adjust names to match the code in `inventory/`.
## Service functions
```python
# inventory/services.py
from django.db import transaction
from .models import StockLevel, StockMovement
class InsufficientStock(Exception):
pass
def _lock_level(shop, part) -> StockLevel:
level, _ = StockLevel.objects.get_or_create(shop=shop, part=part)
return StockLevel.objects.select_for_update().get(pk=level.pk)
def _apply(level: StockLevel, delta: int, reason: str, actor) -> None:
if level.quantity + delta < 0:
raise InsufficientStock(f"{level.part} at {level.shop}: short by {-(level.quantity + delta)}")
StockMovement.objects.create(
shop=level.shop, part=level.part, delta=delta, reason=reason, created_by=actor
)
level.quantity += delta
level.save(update_fields=["quantity"])
def receive_stock(*, shop, part, quantity: int, actor) -> None:
if quantity <= 0:
raise ValueError("quantity must be positive")
with transaction.atomic():
level = _lock_level(shop, part)
_apply(level, quantity, "receive", actor)
def transfer_stock(*, part, source, target, quantity: int, actor) -> None:
if quantity <= 0 or source.pk == target.pk:
raise ValueError("invalid transfer")
with transaction.atomic():
# Lock in a fixed order so two crossing transfers cannot deadlock.
first, second = sorted([source, target], key=lambda s: s.pk)
locked = {first.pk: _lock_level(first, part), second.pk: _lock_level(second, part)}
src, dst = locked[source.pk], locked[target.pk]
_apply(src, -quantity, "transfer_out", actor)
_apply(dst, quantity, "transfer_in", actor)
# Link the two movements with a shared reference (see SKILL.md).
```
## Matching tests
```python
# inventory/tests/test_stock_services.py
from django.contrib.auth import get_user_model
from django.test import TestCase
from inventory.models import Part, StockLevel, StockMovement
from inventory.services import InsufficientStock, receive_stock, transfer_stock
from shops.models import Shop
User = get_user_model()
class StockServiceTests(TestCase):
def setUp(self):
self.shop_a = Shop.objects.create(name="A")
self.shop_b = Shop.objects.create(name="B")
self.part = Part.objects.create(name="Screen")
self.user = User.objects.create_user(username="tech")
def level(self, shop):
return StockLevel.objects.get(shop=shop, part=self.part)
def test_receive_creates_movement_and_increases_level(self):
receive_stock(shop=self.shop_a, part=self.part, quantity=5, actor=self.user)
self.assertEqual(self.level(self.shop_a).quantity, 5)
movement = StockMovement.objects.get(shop=self.shop_a, part=self.part)
self.assertEqual(movement.delta, 5)
self.assertEqual(movement.created_by, self.user)
def test_transfer_moves_stock_and_leaves_other_shop_unaffected(self):
receive_stock(shop=self.shop_a, part=self.part, quantity=5, actor=self.user)
receive_stock(shop=self.shop_b, part=self.part, quantity=1, actor=self.user)
transfer_stock(part=self.part, source=self.shop_a, target=self.shop_b, quantity=3, actor=self.user)
self.assertEqual(self.level(self.shop_a).quantity, 2)
self.assertEqual(self.level(self.shop_b).quantity, 4)
self.assertEqual(StockMovement.objects.filter(part=self.part).count(), 4)
def test_transfer_rejects_negative_and_changes_nothing(self):
receive_stock(shop=self.shop_a, part=self.part, quantity=1, actor=self.user)
before = StockMovement.objects.count()
with self.assertRaises(InsufficientStock):
transfer_stock(part=self.part, source=self.shop_a, target=self.shop_b, quantity=2, actor=self.user)
self.assertEqual(self.level(self.shop_a).quantity, 1)
self.assertEqual(StockMovement.objects.count(), before)
```
.claude/skills/verify/SKILL.md---
name: verify
description: Proves a change works for this Django project by running the full check list and reporting pass or fail per step before work is called done.
when_to_use: Use after any code change, before saying a task is done, and before committing.
---
# Verify
Run these steps in order from the project root with the virtualenv active. Do not skip a step. If a step cannot run, mark it SKIPPED and give the reason. A skipped step means the work is not done.
## Steps
### 1. Django system check
```
python manage.py check
```
### 2. Missing migrations
```
python manage.py makemigrations --check --dry-run
```
This exits non-zero when model changes have no migration. Treat a non-zero exit as FAIL and show which app needs a migration.
### 3. Lint
```
ruff check .
```
Formatting is handled by the PostToolUse hook, so only lint errors fail this step.
### 4. Tests
```
python manage.py test
```
If the output is long, delegate to the `test-runner` subagent. It returns only failing test names, assertion lines, and likely file and line. Report only failures. If everything passes, report the test count.
To run one area, pass the app or test path, for example `python manage.py test inventory`.
### 5. Smoke run
Use the Django test client rather than a live server where possible:
- Hit the login page and confirm it returns 200.
- Log in as a seeded user and hit the stock list for their shop. Confirm 200 and that another shop's parts do not appear.
If a live server is needed, run `python manage.py runserver` briefly and stop it after the first request.
For report changes, run:
```
python manage.py daily_report --date YYYY-MM-DD
```
Use a date present in the seed data. Confirm it prints output and a second run gives the same result (the command is idempotent).
### 6. SMS backend
Confirm the fake SMS backend is active, not the real provider:
```
python manage.py shell -c "from django.conf import settings; print(settings.SMS_BACKEND)"
```
Use the setting name the project defines for the backend. The printed value must name the fake backend. Do not read `.env` to check this. Do not send any real SMS during verification.
## Output format
Print one line per step, then the trimmed failing output:
```
check ............ PASS
migrations ....... PASS
ruff ............. FAIL
inventory/services.py:42: F401 `decimal.Decimal` imported but unused
tests ............ SKIPPED (reason: none)
smoke ............ PASS
report ........... PASS
sms backend ...... PASS (fake)
```
Show only the lines needed to locate each failure. Do not paste full logs.
## Rules
- Never claim the work is done if any step fails or was skipped.
- Fix the failure and rerun the whole list, not just the failed step.
- Never read `.env` or print secrets while verifying.
- Do not commit as part of verification.
INSTALL.md# Install guide
This guide sets up the repair shop tracker on your machine. It assumes you are comfortable with Python and the command line.
## Prerequisites
- Python 3.12
- PostgreSQL running locally
- Claude Code 2.1.296 or newer
## 1. Create the database
Create a local database named `repairshop` in PostgreSQL.
## 2. Set up the project folder
```
mkdir repairshop && cd repairshop
git init
```
Copy in the generated files and keep the paths exactly as listed below.
## 3. Create a virtualenv and install packages
```
python -m venv .venv
source .venv/bin/activate
pip install django psycopg[binary] ruff dj-database-url
```
## 4. Create `.env`
Create `.env` in the project root:
```
DATABASE_URL=postgres://USER:PASSWORD@HOST/DB
SMS_API_KEY=YOUR_API_KEY
```
Add `.env` to `.gitignore`. Never commit it.
## 5. Check the Claude Code version
```
claude --version
```
If the version is below 2.1.296, update Claude Code before continuing.
## 6. Install plugins
Start Claude Code in the project folder with `claude`, then run:
```
/plugin install code-review@claude-plugins-official
/plugin install feature-dev@claude-plugins-official
/plugin install commit-commands@claude-plugins-official
/plugin install security-guidance@claude-plugins-official
```
## 7. Confirm permissions
Run `/permissions` and check that the allow and deny rules from `.claude/settings.json` loaded.
## 8. Start the build
Paste the kickoff prompt from the end of PLAN.md, or the Phase 1 `/goal` prompt. When it finishes, run `/clear` before starting the next phase in PLAN.md.
## Where each file goes
- `CLAUDE.md`: project memory. Claude reads it at the start of each session.
- `SPEC.md`: what the app does and what is out of scope.
- `PLAN.md`: the five build phases, each with a `/goal` prompt.
- `REVIEW.md`: review rules for `/code-review`. CLAUDE.md imports it.
- `INSTALL.md`: this guide.
- `.claude/settings.json`: project permissions, the subagent model, and a formatting hook.
- `.claude/skills/verify/SKILL.md`: the checklist that proves a change works.
- `.claude/skills/stock-change/SKILL.md`: the procedure for any stock change.
- `.claude/skills/stock-change/examples.md`: example service and test code for that procedure.
- `.claude/agents/test-runner.md`: a small subagent that runs tests and returns only failures.
## Keep cost down
- Run `/clear` between unrelated tasks.
- Run `/compact` at natural breaks inside a phase.
- Run `/usage` to see where tokens went.
## Swap the SMS provider
The provider sits behind an interface in `notifications/services.py`. Set the real provider through environment config, as described in Phase 4 of PLAN.md. Tests and local development always use the fake backend, so you can test without sending messages.
## Troubleshooting
**Postgres connection errors.** Check that PostgreSQL is running and that `DATABASE_URL` in `.env` matches your user, password, and database name. Test it with `psql "$DATABASE_URL"` outside Claude Code.
**Missing migrations.** Run `python manage.py makemigrations`, review the new files, then commit them. The verify skill fails until `python manage.py makemigrations --check --dry-run` is clean.
**Hook is not formatting.** The formatting hook runs `ruff format` on edited Python files. If ruff is missing from the virtualenv, the hook exits quietly. Activate `.venv` and run `ruff --version`. Install ruff if it is missing.
PLAN.md# Build plan
Five phases. Run each phase as one /goal in its own session, in order.
## How to run a phase
- Open `claude` in the project folder.
- Run `/clear` first so the session starts empty.
- Paste the phase prompt and let it finish.
- Read the check output yourself before moving on. The goal evaluator only sees what Claude shows in the conversation.
- Run `/compact` at natural breaks inside a long phase.
- Run `/usage` if cost climbs.
- Do not commit until you ask. At the end of a phase, use commit-commands when you are ready.
- Add `ultrathink` to the first message only in phases 2 and 3, and only when designing the stock movement or the status transitions.
## Phase 1: Foundations
```
/goal Phase 1 Foundations for the repair shop tracker. Read CLAUDE.md, SPEC.md and PLAN.md first.
End state: Django 5 project reading Postgres settings from DATABASE_URL; apps shops, inventory, tickets, notifications, reports; Shop model; user-to-shop link with roles technician, front_desk, manager, owner; queryset helper that scopes to the user's shop unless owner; login required on every view; home page listing the user's shop; ruff configured in pyproject.toml; seed command creating 3 shops and one user per role.
Tests: at least 6, covering role scoping (shop A staff cannot see shop B; owner sees all) and login required.
Check: `python manage.py check`, `python manage.py makemigrations --check --dry-run`, `ruff check .` and `python manage.py test` all succeed. Paste each output in the conversation.
Constraints: touch only the five apps and project config; no stock, ticket or SMS code yet; no dependencies beyond django, psycopg, ruff, dj-database-url without asking; never read or print .env; use YOUR_API_KEY placeholders; keep logic in services.py and views thin; do not commit.
Stop after 40 turns and report what remains.
```
## Phase 2: Inventory
```
/goal Phase 2 Inventory for the repair shop tracker. Read CLAUDE.md and SPEC.md first. Use the stock-change skill for all stock work.
End state: Part, StockLevel (unique per shop and part) and StockMovement models; service functions receive, adjust and transfer in inventory/services.py; every change locks the StockLevel row with select_for_update inside transaction.atomic; no function can make stock negative; transfer writes two linked movements in one transaction; stock list view per shop with low-stock flag; receive and adjust screens; all views scoped to the user's shop unless owner.
Tests: scoping, negative stock block, transfer atomicity (failure on the receiving side leaves the sending side unchanged), movement rows written.
Check: `python manage.py check`, `python manage.py makemigrations --check --dry-run`, `ruff check .` and `python manage.py test` all succeed. Paste each output in the conversation.
Constraints: touch only inventory and shops; do not write StockLevel.quantity outside inventory/services.py; no new dependencies without asking; no secrets; do not commit.
Stop after 40 turns and report what remains.
```
## Phase 3: Tickets
```
/goal Phase 3 Tickets for the repair shop tracker. Read CLAUDE.md, SPEC.md and PLAN.md first.
End state: Customer, Ticket, TicketPart and TicketNote models; status values new, diagnosing, waiting_parts, repairing, ready, picked_up, cancelled; an allowed-transition table enforced in tickets/services.py; ready_at set on the first move to ready; adding a part to a ticket decrements stock atomically by calling inventory/services.py; ticket create, edit, status change, notes, and search by phone, name, or ticket number; all views scoped to the user's shop unless owner.
Tests: every allowed transition succeeds, every other transition is rejected; a part added to a ticket reduces stock and creates one movement in the ticket's shop; search respects scoping.
Check: `python manage.py check`, `python manage.py makemigrations --check --dry-run`, `ruff check .` and `python manage.py test` all succeed. Paste each output in the conversation.
Constraints: touch only tickets; inventory may be called but its behavior must not change without asking; no new dependencies without asking; no secrets; do not commit.
Stop after 40 turns and report what remains.
```
## Phase 4: Customer texts
```
/goal Phase 4 Customer texts for the repair shop tracker. Read CLAUDE.md, SPEC.md and PLAN.md first.
End state: notifications/services.py is the only place that sends SMS; an SMS interface with a fake backend (used in tests and local dev) and a real provider adapter chosen by environment config, with SMS_API_KEY read from the environment; NotificationLog records each attempt (ticket, status, provider id, error); a text is sent on the first move to ready only; sms_opt_in is respected; a retry management command resends failed texts; a provider failure never blocks the status change.
Tests: exactly one send across repeated status changes; opt-out means no send attempt; a failing backend still saves the status change and logs the error; tests use the fake backend only.
Check: `python manage.py check`, `python manage.py makemigrations --check --dry-run`, `ruff check .` and `python manage.py test` all succeed. Paste each output in the conversation.
Constraints: touch only notifications and the ticket status hook in tickets; no real network calls in tests; no full phone numbers in logs; no new dependencies, including a provider SDK, without asking; no secrets; do not commit.
Stop after 40 turns and report what remains.
```
## Phase 5: Daily report and polish
```
/goal Phase 5 Daily report and polish for the repair shop tracker. Read CLAUDE.md, SPEC.md and PLAN.md first.
End state: reports/services.py builds per-shop and total figures: revenue from tickets picked up that day, ticket counts by status, parts used with quantity and cost, low-stock list; management command `python manage.py daily_report --date YYYY-MM-DD` that is idempotent and uses the shop's timezone for the day boundary; report page; CSV export with one row per shop plus a total row; seed command extended with sample parts and tickets using fake data only; permission review of all views, with each change listed in your final report.
Tests: a report over fixed data spanning two shops, plus a timezone edge case where pickup happens just before local midnight.
Check: `python manage.py check`, `python manage.py makemigrations --check --dry-run`, `ruff check .`, `python manage.py test` and `python manage.py daily_report --date` against seed data all succeed. Paste each output in the conversation.
Constraints: touch only reports, shops (seed command) and views for the permission review; no real customer data; no new dependencies without asking; no secrets; do not commit.
Stop after 40 turns and report what remains.
```
REVIEW.md# Review rules
Use these rules when reviewing a diff. Flag real problems, not style.
## Stock and ledger
- Flag any direct write to `StockLevel.quantity` outside `inventory/services.py`.
- Flag any stock change not wrapped in `transaction.atomic` with `select_for_update` on the stock row.
- Flag any stock change that can leave a negative quantity.
- Flag missing migrations for model changes.
- Flag missing tests for new service functions.
## Scoping and data
- Flag queries missing shop scoping for non-owner users.
- Flag float use for money. Use `Decimal` and `DecimalField`.
- Flag secrets, API keys, or real customer data in code, fixtures, or docs.
## SMS and logs
- Flag SMS sends outside `notifications/services.py`.
- Flag any code path that sends SMS during tests. Tests use the fake backend.
- Flag full customer phone numbers in logs, exceptions, or messages.
## Performance
- Flag N+1 queries in list views and reports. Expect `select_related` or `prefetch_related`.
## Ignore
- Pure formatting that ruff handles.
SPEC.md# Repair shop tracker: spec
## Purpose
Internal tool for three phone repair shops and one owner. Staff track parts stock per shop, repair tickets, customer ready texts, and a daily revenue and parts report.
## Users and roles
- technician: creates and updates tickets and the parts used on them, in their own shop.
- front_desk: creates tickets, manages customers, and changes ticket status in their own shop.
- manager: everything above, plus stock receive, adjust, and transfer, and reports for their own shop.
- owner: sees and manages all shops.
- Staff see only their own shop. The owner sees all.
- Roles live on a user-to-shop link. Each role is also a Django group so permissions can attach to it.
- Auth uses Django built-in auth. No public pages.
## Models
- Shop: name, code, timezone.
- Part: name, sku, unit_cost.
- StockLevel: shop, part, quantity, reorder_threshold. Unique on (shop, part).
- StockMovement: shop, part, delta, reason, ticket (nullable), created_by, created_at. Append only.
- Customer: name, phone, sms_opt_in.
- Ticket: shop, customer, device, issue, status, quoted_price, final_price, created_at, ready_at.
- TicketPart: ticket, part, quantity, unit_price.
- TicketNote: ticket, author, body, created_at.
- NotificationLog: ticket, status, provider_id, error, created_at.
Ticket statuses: new, diagnosing, waiting_parts, repairing, ready, picked_up, cancelled.
## Features
Stock:
- Stock list per shop with a low-stock flag (quantity at or below reorder_threshold).
- Receive and adjust screens. Adjust requires a reason.
- Transfer between shops as two linked movements, one out and one in, in one transaction.
Tickets:
- Create, edit, change status, add parts, add notes.
- Search by phone, customer name, or ticket number.
Customer texts:
- When a ticket becomes ready, send one SMS with the shop name and ticket number.
- Record each attempt in NotificationLog.
- Respect sms_opt_in.
- Failed sends are retried by a management command. A failure never blocks the status change.
Daily report:
- Per shop and total: revenue from tickets picked up that day, ticket count by status, parts used with quantity and cost, low-stock list.
- Shown on a page, and emailed or printed as plain text.
- CSV export.
## Non-goals for v1
- Online customer portal.
- Payment processing.
- Barcode scanning.
- Mobile app.
- Purchase orders.
## Acceptance criteria
Stock:
- Receive increases quantity and writes one StockMovement.
- An adjust that would go below zero is rejected with a clear message.
- A transfer moves quantity from shop A to shop B atomically. If the receiving side fails, shop A is unchanged.
- The low-stock flag shows when quantity is at or below reorder_threshold.
- Staff of shop A cannot see or change shop B stock.
Tickets:
- Only the transitions in the allowed table succeed. Others are rejected (for example, picked_up back to new).
- Adding a part to a ticket creates a negative StockMovement in the ticket's shop.
- Setting status to ready sets ready_at the first time only.
- Phone search returns only matching tickets the user may see.
Texts:
- Moving a ticket to ready sends exactly one SMS with shop name and ticket number.
- A customer with sms_opt_in false gets no send attempt.
- A provider failure saves the status change and stores the error in NotificationLog.
- The retry command resends failed texts and does not duplicate successful ones.
- Tests use the fake backend. No test sends a real SMS.
- Full phone numbers never appear in logs.
Report:
- Revenue counts only picked_up tickets whose pickup falls on the requested day in the shop's timezone.
- Running the report twice for the same date gives the same output.
- CSV has one row per shop plus a total row.
- Parts used lists quantity and cost per part.
## Open questions
- Which SMS provider?
- Currency and tax: is tax included in prices or added? One currency is assumed.
- Timezone: stored per shop on Shop. Confirm the three shop timezones.
- When a ticket is cancelled, do its used parts return to stock?
- Exact edit rights per role.
- Who receives the emailed report?