Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
56b6eed
feat: implement partner inventory management and integrate into conte…
fleuronvilik Mar 19, 2026
fb35737
feat: enhance repository methods with autocommit option for better tr…
fleuronvilik Mar 20, 2026
6bcbf05
refactor(delivery-request): make entity immutable and update DR workflow
fleuronvilik Mar 30, 2026
c3195bb
refactor(domain): centralize domain exceptions
fleuronvilik Mar 30, 2026
c363e36
test: align helpers and policy scenarios with immutable flow
fleuronvilik Mar 30, 2026
f9b03b8
refactor(sales-report): make entity immutable
fleuronvilik Mar 31, 2026
05e7a4f
refactor(typing): replace legacy collection aliases
fleuronvilik Mar 31, 2026
11c7c18
refactor(partner-inventory): make entity immutable
fleuronvilik Mar 31, 2026
5272013
refactor(delivery-request): rename save_status to save
fleuronvilik Mar 31, 2026
fddd3c5
fix(partner-inventory): align naming and imports
fleuronvilik Mar 31, 2026
30a8fd8
fix(void): handle missing inventory on sales report void
fleuronvilik Mar 31, 2026
764ad30
fix(void): raise DataIntegrityError on missing inventory lines in voi…
Copilot Mar 31, 2026
f1daea3
chore(tests): clean up outdated test code and comments
fleuronvilik Mar 31, 2026
756d493
fix(delivery-request): comment out unused __str__
fleuronvilik Mar 31, 2026
0b6d38a
docs(void): explain skipped data integrity handling
fleuronvilik Mar 31, 2026
2464db2
fix(tests): save updated delivery request status in repo test
fleuronvilik Mar 31, 2026
a5c56a7
feat(void): extract inventory restore helper
fleuronvilik Mar 31, 2026
6a0e8b6
tests(void): add data integrity error case for missing inventory
fleuronvilik Mar 31, 2026
1533001
fix: correct typos and align restore method naming
fleuronvilik Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
from pathlib import Path
from typing import Iterable

from app.audit import InMemoryAudit
from app.context import Context
from infra.sql.sql_audit_repo import SqlAuditRepo
from infra.sql.sql_delivery_request_repo import SqlDeliveryRequestRepo
from infra.sql.sql_partner_inventory_repo import SqlPartnerInventoryRepo
from infra.sql.sql_sales_report_repo import SqlSalesReportRepo
from policies.identity import Actor, Role

Expand Down Expand Up @@ -55,6 +55,7 @@ def make_ctx(
catalog=catalog,
dr_repo=SqlDeliveryRequestRepo(conn),
sr_repo=SqlSalesReportRepo(conn),
pi_repo=SqlPartnerInventoryRepo(conn),
audit=SqlAuditRepo(conn),
)

Expand Down
2 changes: 2 additions & 0 deletions app/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
# from app.audit import InMemoryAudit
from infra.sql.sql_audit_repo import SqlAuditRepo
from infra.sql.sql_delivery_request_repo import SqlDeliveryRequestRepo
from infra.sql.sql_partner_inventory_repo import SqlPartnerInventoryRepo
from infra.sql.sql_sales_report_repo import SqlSalesReportRepo


Expand All @@ -16,4 +17,5 @@ class Context:
catalog: Iterable[str]
dr_repo: SqlDeliveryRequestRepo
sr_repo: SqlSalesReportRepo
pi_repo: SqlPartnerInventoryRepo
audit: SqlAuditRepo
24 changes: 21 additions & 3 deletions app/helpers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# from app.errors import NotFound
from .context import Context
from .errors import NotFound
from domain.sales_report import SalesReport
from app.context import Context
from app.errors import NotFound
from infra.errors import DataIntegrityError


def get_dr_or_raise(ctx: Context, dr_id: int):
Expand All @@ -15,3 +16,20 @@ def get_sr_or_raise(ctx: Context, sr_id: int):
if not sr:
raise NotFound(f"sales report not found: {sr_id}")
return sr


def restore_quantities_or_raise(
ctx: Context, sr: SalesReport, autocommit: bool
) -> None:
missing_items_ids = []
for it in sr.items:
pi = ctx.pi_repo.get(sr.partner_id, it.book_id)
if pi is None:
missing_items_ids.append(it.book_id)
continue
pi = pi.restore_sale(it.quantity)
ctx.pi_repo.save(pi, autocommit=False)
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

restore_quantities_or_raise accepts an autocommit parameter but ignores it and always calls ctx.pi_repo.save(..., autocommit=False). This is misleading for callers and could cause subtle transaction bugs if the function is reused elsewhere. Either remove the parameter or thread it through consistently (and document the expected transaction boundary).

Suggested change
ctx.pi_repo.save(pi, autocommit=False)
ctx.pi_repo.save(pi, autocommit=autocommit)

Copilot uses AI. Check for mistakes.
if missing_items_ids:
raise DataIntegrityError(
f"Sales report with id {sr.id} contains following items ({', '.join(missing_items_ids)}), for which no inventory line exists"
)
Comment on lines +32 to +35
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The raised DataIntegrityError message for missing inventory lines does not include partner_id, even though this error is partner-scoped and the PR description mentions including it. Including sr.partner_id in the message would make debugging much easier (especially if the same sr.id could exist across environments/log streams).

Copilot uses AI. Check for mistakes.
21 changes: 20 additions & 1 deletion app/queries.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,29 @@
# from collections import defaultdict
from typing import List
from typing import Dict, List

from domain.sales_report import SalesReport
from infra.sql.sql_partner_inventory_repo import SqlPartnerInventoryRepo


def reports_by_partner(
reports: List[SalesReport], partner_id: str
) -> List[SalesReport]:
return [r for r in reports if r.partner_id == partner_id]


def get_partner_inventory(
partner_id: str, pi_repo: SqlPartnerInventoryRepo
) -> Dict[str, int]:
rows = (
pi_repo.conn.cursor()
.execute(
"""
SELECT book_sku, current_quantity
FROM partner_inventories
WHERE partner_id = ?
""",
(partner_id,),
)
.fetchall()
)
return {sku: qty for sku, qty in rows}
179 changes: 99 additions & 80 deletions app/use_cases.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from typing import List, Tuple

from domain.errors import InsufficientStock
from domain.partner_inventory import PartnerInventory
from domain.delivery_request import RequestItem, DeliveryRequest
from domain.sales_report import AlreadyVoided, SalesReport, ReportItem
from policies.identity import Forbidden, Role, Actor
Expand All @@ -10,16 +10,16 @@
from policies.validations import (
validate_report_items_in_catalog,
validate_request_items_in_catalog,
validate_sales_report_against_stock,
)
from .context import Context
from .helpers import get_dr_or_raise, get_sr_or_raise
from .helpers import get_dr_or_raise, get_sr_or_raise, restore_quantities_or_raise
from .errors import ValidationError
# from infra.errors import DataIntegrityError # leaving it there for now
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a commented-out DataIntegrityError import left in the module. Given infra/errors.py is now part of the PR, either wire it into void_sales_report (and any other relevant integrity checks) or remove this comment to avoid dead/unclear code paths.

Suggested change
# from infra.errors import DataIntegrityError # leaving it there for now

Copilot uses AI. Check for mistakes.


def create_delivery_request(
ctx: Context, actor: Actor, payload: List[RequestItem]
) -> Tuple[int, DeliveryRequest]:
ctx: Context, actor: Actor, payload: list[RequestItem]
) -> tuple[int, DeliveryRequest]:
if actor.role is not Role.PARTNER:
raise Forbidden("only PARTNER can create a delivery request")

Expand All @@ -33,12 +33,13 @@ def create_delivery_request(
validate_request_items_in_catalog(dr.items, ctx.catalog)

dr_id = ctx.dr_repo.create(dr)
dr = get_dr_or_raise(ctx, dr_id)
return dr_id, dr


def submit_delivery_request(
ctx: Context, actor: Actor, dr_id: int
) -> Tuple[int, DeliveryRequest]:
) -> tuple[int, DeliveryRequest]:
# AuthZ minimale (comme pour submit SR)
if actor.role is not Role.PARTNER:
raise Forbidden("only PARTNER can submit a delivery request")
Expand All @@ -57,83 +58,123 @@ def submit_delivery_request(
partner_id=dr.partner_id, dr_repo=ctx.dr_repo, sr_repo=ctx.sr_repo
)

dr.submit()
ctx.dr_repo.save_status(dr_id, dr.status)
dr = dr.submit()
ctx.dr_repo.save(dr)
return dr_id, dr


def approve_delivery_request(
ctx: Context, actor: Actor, dr_id: int
) -> Tuple[int, DeliveryRequest]:
) -> tuple[int, DeliveryRequest]:
# AuthZ minimale (comme pour submit SR)
if actor.role is not Role.ADMIN:
raise Forbidden("only ADMIN can approve a delivery request")

dr = get_dr_or_raise(ctx, dr_id)
dr.approve()
ctx.dr_repo.save_status(dr_id, dr.status)
dr = dr.approve()
ctx.dr_repo.save(dr)
return dr_id, dr


def reject_delivery_request(
ctx: Context, actor: Actor, dr_id: int, reason: str
) -> Tuple[int, DeliveryRequest]:
) -> tuple[int, DeliveryRequest]:
if actor.role != Role.ADMIN:
raise Forbidden("only an ADMIN can reject a deliery request")
raise Forbidden("only an ADMIN can reject a delivery request")

# reason obligatoire
if reason is None or not str(reason).strip():
raise ValidationError("reject reason is required")

dr = get_dr_or_raise(ctx, dr_id)

# Audit requis (si audit absent/KO -> on échoue)
ctx.audit.record(
{
"type": "DR_REJECTED",
"target_type": "delivery_request",
"target_id": dr_id,
"reason": reason,
}
)

# Transition métier (idéalement: l'entité refuse si state != SUBMITTED)
dr.reject()
try:
dr = get_dr_or_raise(ctx, dr_id)

# Audit requis (si audit absent/KO -> on échoue)
ctx.audit.record(
{
"type": "DR_REJECTED",
"target_type": "delivery_request",
"target_id": dr_id,
"reason": reason,
},
autocommit=False,
)

# Transition métier (idéalement: l'entité refuse si state != SUBMITTED)
dr = dr.reject()
ctx.dr_repo.save(dr, autocommit=False)
ctx.dr_repo.conn.commit()
except Exception:
ctx.dr_repo.conn.rollback()
raise

return dr_id, dr


def mark_delivered(
ctx: Context, actor: Actor, dr_id: int
) -> Tuple[int, DeliveryRequest]:
) -> tuple[int, DeliveryRequest]:
if actor.role is not Role.ADMIN:
raise Forbidden("only ADMIN can mark a delivery request delivered")

dr = get_dr_or_raise(ctx, dr_id)
dr.mark_delivered()
ctx.dr_repo.save_status(dr_id, dr.status)
try:
dr = get_dr_or_raise(ctx, dr_id)
dr = dr.mark_delivered()
for items in dr.items:
pi = ctx.pi_repo.get(dr.partner_id, items.book_id)
if pi is None:
pi = PartnerInventory(
partner_id=dr.partner_id, book_sku=items.book_id, current_quantity=0
)
pi = pi.deliver(items.quantity)
ctx.pi_repo.save(pi, autocommit=False)
ctx.dr_repo.save(dr, autocommit=False)
ctx.dr_repo.conn.commit()
except Exception:
ctx.dr_repo.conn.rollback()
raise
return dr_id, dr


def submit_sales_report(
ctx: Context, actor: Actor, payload: List[ReportItem]
) -> Tuple[int, SalesReport]:
ctx: Context, actor: Actor, payload: list[ReportItem]
) -> tuple[int, SalesReport]:
# AuthZ minimale (rôles) : hors SR
if actor.role != Role.PARTNER:
raise Forbidden("only PARTNER can submit a sales report")

report = SalesReport(partner_id=actor.partner_id, items=payload) # invariants SR
report = SalesReport(
id=None, partner_id=actor.partner_id, items=payload
) # invariants SR
validate_report_items_in_catalog(report, ctx.catalog) # policy externe
validate_sales_report_against_stock(
report=report, dr_repo=ctx.dr_repo, sr_repo=ctx.sr_repo
)
sr_id = ctx.sr_repo.create(report) # persistance mémoire

working = {}
for it in report.items:
key = (report.partner_id, it.book_id)
if key not in working:
pi = ctx.pi_repo.get(report.partner_id, it.book_id)
if pi is None:
raise InsufficientStock(
f"Cannot report sale of {it.quantity} for {it.book_id}, no copy available"
)
pi = pi.report_sale(it.quantity)
working[key] = pi # .clone()

try:
sr_id = ctx.sr_repo.create(report, autocommit=False) # persistance mémoire
for pi in working.values():
ctx.pi_repo.save(pi, autocommit=False)
ctx.sr_repo.conn.commit()
report = get_sr_or_raise(ctx, sr_id)
except Exception:
ctx.sr_repo.conn.rollback()
raise
return sr_id, report


def void_sales_report(
ctx: Context, actor: Actor, sr_id: int, reason: str
) -> Tuple[int, SalesReport]:
) -> tuple[int, SalesReport]:
if actor.role != Role.ADMIN:
raise Forbidden("only an ADMIN can void a sales report")

Expand All @@ -143,48 +184,26 @@ def void_sales_report(
sr = get_sr_or_raise(ctx, sr_id)
if sr.voided:
raise AlreadyVoided(f"sales report with id {sr_id} is already voided")
ctx.sr_repo.mark_void(sr_id)

ctx.audit.record(
{
"type": "SR_VOIDED",
"target_type": "sales_report",
"target_id": sr_id,
"reason": reason,
}
)

# ctx.sr_repo.mark_void(sr_id)
return sr_id, get_sr_or_raise(ctx, sr_id)


# def list_reports_by_partner(
# *,
# actor: Actor,
# partner_id: str,
# sr_repo: InMemorySalesReportRepo,
# ) -> List[SalesReport]:
# # Rule: ADMIN must provide a partner_id (no "list all" shortcut in this use-case).
# if actor.role is not Role.ADMIN:
# raise Forbidden("only ADMIN can list reports for an arbitrary partner")

# if not partner_id:
# raise ValueError("partner_id is required")
try:
restore_quantities_or_raise(ctx, sr, autocommit=False)
ctx.sr_repo.mark_void(sr_id, autocommit=False)
ctx.audit.record(
{
"type": "SR_VOIDED",
"target_type": "sales_report",
"target_id": sr_id,
"reason": reason,
},
autocommit=False,
)
ctx.sr_repo.conn.commit()
report = get_sr_or_raise(ctx, sr_id)
except Exception:
ctx.sr_repo.conn.rollback()
raise

# return reports_by_partner(sr_repo.list_all(), partner_id)


# def list_my_reports(
# *,
# actor: Actor,
# sr_repo: InMemorySalesReportRepo,
# ) -> List[SalesReport]:
# # Rule: "my reports" is a PARTNER-only intention.
# if actor.role is not Role.PARTNER:
# raise Forbidden("only PARTNER can list their own reports")

# # Actor invariant guarantees partner_id is present for PARTNER.
# return reports_by_partner(sr_repo.list_all(), actor.partner_id)
return sr_id, report


def get_sales_report(ctx: Context, actor: Actor, sr_id: int) -> SalesReport | None:
Expand Down
Loading
Loading