El plànol és sobre la taula; avui es posen els maons. Aquesta lliçó t'acompanya fita a fita, però no et fa la feina: hi trobaràs esquelets, contractes i punts de control, i una única peça resolta del tot — el ServeiVendes — perquè integra en trenta línies cinc mòduls del curs i mereix veure's sencera i comentada. De la resta n'implementes tu, verificant cada fita contra la seva "demo" de 12-02 abans de passar a la següent. També hi trobaràs el que cap tutorial no explica: les trampes típiques d'integració i un protocol per a quan t'encallis (que t'encallaràs — està previst i no és una mala notícia).

Contingut

  1. H1 — Domini: reutilitzar amb criteri
  2. H2 — Serveis: ServeiVendes, la peça resolta
  3. H3 — Persistència: repositoris amb guardat atòmic
  4. H4 — Interfície: un exemple complet, la resta per contracte
  5. H5 — Informe: esquelet amb sortides esperades
  6. H6 — Poliment: logging, README, requirements
  7. Treballar en passos petits (i la menció honesta a git)
  8. Trampes d'integració típiques
  9. Protocol d'encallada en 5 passos

H1 — Domini: reutilitzar amb criteri

La primera fita és un trasllat amb neteja: portar les peces de M5-M7 al paquet nou, revisant-les contra els contractes de 12-02. Checklist de què es reutilitza tal qual i què canvia:

Peça Origen Canvia?
dataclass Llibre amb preu_final(soci) i hi_ha_estoc() M5 (05-06) S'hi afegeix el camp autor (RF1 ho exigeix) i type hints complets
Jerarquia ErrorPapyrus i les seves 4 filles M7 (07-04) Tal qual — ja era exactament el que RNF3 demana
CUPONS i aplicar_cupo M7 Tal qual
Soci Nou @dataclass(frozen=True) amb codi, nom, alta — immutable perquè ningú no ha de mutar un soci a mitja venda
IVA_LLIBRES, DESCOMPTE_SOCI M1 Tal qual (0.04 i 0.05); viuen a models.py al costat de qui les usa

Punt de control H1 (en un REPL des de l'arrel del projecte):

>>> from papyrus.models import Llibre
>>> Llibre("L'Odissea", "Homer", 12.50, 4).preu_final(soci=True)
12.35
>>> from papyrus.cupons import aplicar_cupo
>>> aplicar_cupo(100.0, "PAPYRUS10")
90.0
>>> aplicar_cupo(100.0, "NOEXISTEIX")   # ha de llancar CupoInvalidError

Si el primer número no és 12.35, atura't i arregla-ho ara: tot el sistema es recolzarà en aquesta fórmula (preu × 1.04 × 0.95, arrodoniment a 2 decimals).

H2 — Serveis: la peça resolta

ServeiVendes.vendre és el cor de Papyrus Online: valida contra el catàleg (M4/M5), llança errors propis (M7), calcula el preu amb soci i cupó (M1/M3), persisteix (M6) i deixa rastre al log (M7). Per això és l'única peça que donem completa — llegeix-la línia a línia, perquè és el patró que repetiràs en tota la resta:

# papyrus/serveis.py
from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import date

from papyrus.cupons import aplicar_cupo
from papyrus.errors import (EstocInsuficientError, LlibreNoTrobatError,
                            SociInvalidError)
from papyrus.models import Llibre, Soci
from papyrus.repositoris import (RegistreVendes, RepositoriCataleg,
                                 RepositoriSocis)

logger = logging.getLogger("papyrus")           # el logger de M7, ja configurat a l'app


@dataclass(frozen=True)
class Venda:
    """Resultat immutable d'una venda: el que s'anota a vendes.csv."""
    data: str
    titol: str
    unitats: int
    quantia: float


class ServeiVendes:
    """Orquestra una venda completa: valida, calcula, persisteix i registra."""

    def __init__(self, repo_cataleg: RepositoriCataleg,
                 repo_socis: RepositoriSocis,
                 registre: RegistreVendes) -> None:
        # El servei rep els repositoris ja construits (els tests de 12-04
        # li passaran repositoris sobre tmp_path: aquesta es la rao del disseny).
        self._repo_cataleg = repo_cataleg
        self._registre = registre
        self._cataleg: dict[str, Llibre] = repo_cataleg.carregar()
        self._socis: dict[str, Soci] = repo_socis.carregar()

    def vendre(self, titol: str, unitats: int,
               codi_soci: str | None = None,
               cupo: str | None = None) -> Venda:
        # ---- FASE 1: validar TOT abans de tocar RES (regla d'or) ----
        if titol not in self._cataleg:
            logger.warning("Venda rebutjada: %s no es al cataleg", titol)
            raise LlibreNoTrobatError(titol)
        llibre = self._cataleg[titol]

        if llibre.estoc < unitats:
            logger.warning("Venda rebutjada: %s en demanava %d i n'hi ha %d",
                           titol, unitats, llibre.estoc)
            raise EstocInsuficientError(titol, unitats, llibre.estoc)

        es_soci = codi_soci is not None
        if es_soci and codi_soci not in self._socis:
            logger.warning("Venda rebutjada: soci %s desconegut", codi_soci)
            raise SociInvalidError(codi_soci)

        # ---- FASE 2: calcular (domini pur, sense efectes) ----
        preu = llibre.preu_final(soci=es_soci)        # IVA + descompte soci (M5)
        if cupo is not None:
            preu = aplicar_cupo(preu, cupo)           # CupoInvalidError si no existeix
        quantia = round(preu * unitats, 2)            # un unic arrodoniment final

        # ---- FASE 3: mutar i persistir (nomes si TOT l'anterior ha passat) ----
        llibre.estoc -= unitats
        self._repo_cataleg.guardar(self._cataleg)     # decisio de 12-02: guardar per venda
        venda = Venda(date.today().isoformat(), titol, unitats, quantia)
        self._registre.anotar(venda)
        logger.info("Venda: %d x %s → %.2f EUR (soci=%s, cupo=%s)",
                    unitats, titol, quantia, codi_soci, cupo)
        return venda

Tres coses que fan professional aquest codi i que has de conservar en el teu:

  • Les tres fases: validar → calcular → mutar. Si CupoInvalidError salta a la fase 2, l'estoc encara no s'ha tocat — RF3 (res no canvia si alguna cosa falla) es compleix per construcció, no per sort. Fixa't que el cupó es valida a la fase 2 però abans de la fase 3: l'excepció d'aplicar_cupo ens continua protegint.
  • El log explica la història: cada rebuig és un WARNING amb dades concretes (RF7). A 12-04 el log serà el teu testimoni entre capes.
  • Depèn de repositoris, no de rutes: el servei no sap on és cataleg.json. Qui el construeix ho decideix — i als tests, "qui el construeix" usarà tmp_path.

Punt de control H2 (script ràpid): vendre Faust ×1 amb LLUIS-001 i PAPYRUS10 ha de retornar una Venda amb quantia 18.67, i l'estoc de Faust ha de quedar en 9. Completa tu reposar() i tancament_de_caixa() amb el mateix patró de fases (els tens com a exercici al final).

H3 — Persistència: guardat atòmic

Els repositoris tradueixen entre disc i objectes, sense ni una gota de negoci. Esquelet de RepositoriCataleg — implementa els # TODO:

# papyrus/repositoris.py
import csv, json, os
from dataclasses import asdict
from pathlib import Path
from papyrus.models import Llibre

class RepositoriCataleg:
    def __init__(self, ruta: Path) -> None:
        self._ruta = ruta

    def carregar(self) -> dict[str, Llibre]:
        # TODO: llegir JSON (M6) i construir {titol: Llibre(**dades)}.
        # Recorda: encoding="utf-8" sempre, i deixa que FileNotFoundError
        # es propagui — un cataleg absent es un error tecnic, no de negoci.
        ...

    def guardar(self, cataleg: dict[str, Llibre]) -> None:
        temporal = self._ruta.with_suffix(".json.tmp")
        # TODO 1: bolcar [asdict(llibre) for llibre in cataleg.values()]
        #         al fitxer temporal amb json.dump(..., indent=2, ensure_ascii=False).
        # TODO 2: os.replace(temporal, self._ruta)
        #         os.replace es atomic: el cataleg real o es el vell o es el
        #         nou COMPLET, mai un fitxer a mitges (la idea del
        #         TransaccioCataleg de M8, en la seva versio minima).
        ...

RepositoriSocis és anàleg (més simple: només carrega). RegistreVendes.anotar obre vendes.csv en mode "a" (append, M6), escriu la capçalera només si el fitxer no existia, i afegeix una fila per venda amb csv.writer. Punt de control H3: ven des d'un script, obre cataleg.json a mà i comprova l'estoc; executa el script una altra vegada i verifica que el CSV té dues files i una sola capçalera.

H4 — Interfície: un exemple complet, la resta per contracte

La recompensa del disseny: els endpoints són nans. Exemple complet del més ric, POST /api/vendes (itinerari A), inclosa la traducció d'errors amb errorhandler (10-03):

# app.py (fragment)
from flask import Flask, jsonify, request
from papyrus.errors import (CupoInvalidError, EstocInsuficientError,
                            LlibreNoTrobatError, SociInvalidError)

app = Flask(__name__)
servei = crear_servei()          # funcio teva que munta repos + servei

@app.errorhandler(LlibreNoTrobatError)
def _no_trobat(err): return jsonify(error=str(err)), 404

@app.errorhandler(EstocInsuficientError)
def _sense_estoc(err): return jsonify(error=str(err)), 409

@app.errorhandler(SociInvalidError)
@app.errorhandler(CupoInvalidError)
def _peticio_invalida(err): return jsonify(error=str(err)), 400

@app.post("/api/vendes")
def crear_venda():
    dades = request.get_json(silent=True)
    if not dades or "titol" not in dades or "unitats" not in dades:
        return jsonify(error="Falten camps: titol, unitats"), 400
    venda = servei.vendre(dades["titol"], int(dades["unitats"]),
                          dades.get("soci"), dades.get("cupo"))
    return jsonify(data=venda.data, titol=venda.titol,
                   unitats=venda.unitats, quantia=venda.quantia), 201

Mira el que no hi ha: ni preus, ni estoc, ni fitxers. L'endpoint valida la forma de la petició (és JSON?, hi són els camps?) i delega el fons al servei; els errorhandler tradueixen cada error de domini al seu codi HTTP una sola vegada per a tota l'app. La resta de rutes les implementes tu per contracte (la teva fita 0.5): GET /api/llibres (200), GET /api/llibres/<titol> (200/404), POST /api/llibres (201/409 si duplicat), PUT (200/404), DELETE (204/404).

Itinerari B: l'equivalent és una vista vendre amb un ModelForm (10-05) el clean() del qual crida el servei dins d'un try/except ErrorPapyrus as err: i converteix l'error en form.add_error(None, str(err)). El catàleg viu a l'ORM, però la regla és idèntica: la vista tradueix, el servei resol. Punt de control H4: els criteris de RF5 de 12-01, provats amb curl o amb el navegador.

H5 — Informe: esquelet amb sortides esperades

generar_informe és un guió de M11 empaquetat com a funció. Esquelet:

# papyrus/informe.py
def generar_informe(ruta_csv: Path, sortida_png: Path) -> ResumMes:
    vendes = pd.read_csv(ruta_csv, parse_dates=["data"])
    # TODO 1: unitats i quantia totals (vendes["unitats"].sum(), ...)
    # TODO 2: top 3 titols per unitats (groupby("titol") + sum + nlargest)
    # TODO 3: unitats per dia de la setmana (dt.day_name() + groupby, M11)
    # TODO 4: grafic de barres per mes amb plt.savefig(sortida_png) — i
    #         plt.close() despres, o els tests acumularan figures obertes.
    # TODO 5: retornar ResumMes(...) amb tot l'anterior.
    ...

Punt de control H5 — executa'l sobre el vendes_2026.csv de M11 (copia'l a dades/); els teus números han de clavar els canònics:

Mètrica Valor esperat
Files del CSV 487
Unitats totals (gen-jun) 520
Millor dia de la setmana dissabte (130 u)
Millor mes abril (168 u — Sant Jordi)
El PNG existeix a dades/ i s'obre

Si un número no quadra, tens un bug reproduïble amb dades conegudes — el millor tipus de bug que existeix.

H6 — Poliment

  • Logging: configura'l una sola vegada a l'arrencada (logging.basicConfig cap a dades/papyrus.log, format amb data i nivell, com a 07-05) i verifica RF7: provoca un rebuig per estoc i busca el seu WARNING al fitxer.
  • requirements.txt: pip freeze > requirements.txt des del teu venv… i després edita'l a mà deixant-hi només el que és directe (flask o django, pandas, matplotlib, pytest). Un requirements de 40 línies per a 5 dependències és soroll.
  • README: a 12-05 tens la plantilla completa; de moment, que existeixi amb instal·lació i arrencada.

Treballar en passos petits (i la menció honesta a git)

La disciplina que més protegeix un projecte és avançar en passos petits i verificats: una funció, la seva prova, el seu punt de control — i només llavors la següent. Els professionals materialitzen això amb git, fent un commit (una foto del projecte) per cada pas: si alguna cosa es trenca, es compara amb l'última foto bona. Aquest curs no ha ensenyat git i no fingirem en tres línies que ho ha fet; dos camins honestos:

  • Si no coneixes git: en tancar cada fita, copia el projecte a versions/fita-1/, versions/fita-2/… És rudimentari, però compleix la funció d'"última foto bona". I apunta't git com a primera extensió post-curs (12-05): és, sense discussió, el següent que has d'aprendre.
  • Si ja el fas servir: un commit per punt de control, amb un missatge que digui què funciona ja ("H3: el cataleg persisteix amb guardat atomic").

Trampes d'integració típiques

Les peces que funcionen soles fallen juntes. Aquesta taula t'estalviarà hores:

Símptoma Causa probable On es va explicar
ModuleNotFoundError: papyrus Executes des de la carpeta equivocada; el paquet no és al path M3 (03-04)
Quanties amb cèntims ballant (18.68 vs 18.67) Arrodoneixes a cada capa; hi ha d'haver un arrodoniment final per quantia M1 (floats), H2
TypeError: '<' not supported between 'str' and 'int' El CSV entrega text i ningú no l'ha convertit: la persistència entrega tipus, era la seva feina M6, 12-02
FileNotFoundError: dades/cataleg.json en llançar des d'una altra carpeta Rutes relatives al directori d'execució; usa rutes ancorades al fitxer (Path(__file__).parent) M6 (06-04)
json.JSONDecodeError en arrencar Un guardat anterior va morir a mitges: el teu guardar no era atòmic de veritat M8 (08-04), H3
Tests que passen sols i fallen en suite Estat compartit: tots els tests escriuen al mateix dades/; usa tmp_path M9, 12-04
Accents trencats al JSON (titol) Falta ensure_ascii=False i encoding="utf-8" M6 (06-03)
ImportError circular entre serveis i repositoris Una capa inferior importa d'una de superior: el disseny de 12-02 s'ha invertit M3, 12-02

Protocol d'encallada en 5 passos

Encallar-se forma part del pla. El que distingeix el professional no és no encallar-se: és tenir protocol (és el mètode de 09-04/09-05, aplicat al sistema sencer):

  1. Reprodueix en petit: aïlla la fallada en un script de 5 línies fora de la web. Si vendre() falla, no el depuris a través d'HTTP.
  2. Llegeix el traceback de baix a dalt: l'última línia diu què; la primera línia teva (no de Flask ni de pandas) diu on.
  3. Interroga el log: papyrus.log explica què va passar abans de la fallada. Si el log no diu res d'útil, aquesta és una millora per fer ja (i quedarà feta per al següent bug).
  4. pdb a la frontera: breakpoint() just on una capa crida la següent, i inspecciona què entra i què surt (09-05). El 80 % dels bugs d'integració són "jo creia que em passaves X i em passaves Y".
  5. Explica-ho per escrit: dues frases a DECISIONS.md: "espero A, passa B". La meitat de les vegades, en escriure la segona frase veus l'error. Si no, descansa i torna-hi: el cervell continua treballant gratis.

Amb 30-45 minuts honestos de protocol és lícit buscar ajuda fora (documentació, fòrums) — però hi arribaràs amb el problema ja acotat, que és com es pregunta bé.

Errors Comuns i Consells

  • Copiar el ServeiVendes sense llegir-lo. És l'única peça regalada del projecte; el seu valor no és estalviar-te teclejar, és ensenyar-te el patró validar-calcular-mutar que has de replicar a reposar, als endpoints i a l'informe.
  • Avançar dues fites amb punts de control pendents. "Ja ho comprovaré tot junt" multiplica el cost de cada bug: si H4 falla i H2-H3 no estaven verificades, el sospitós pot ser a tres llocs en comptes d'un.
  • Configurar logging a cada mòdul. Es configura una vegada a l'arrencada; els mòduls només fan getLogger("papyrus"). Dos basicConfig = línies duplicades o absents al log.
  • Perfeccionar HTML/CSS a H4. L'itinerari B no demana una web bonica; demana una web correcta. El poliment visual és un forat de temps sense cap requisit que el respaldi.
  • No usar les dades canòniques. Els quatre llibres i vendes_2026.csv existeixen perquè sàpigues què esperar. Amb dades inventades, un resultat estrany no saps si és bug o dada.

Exercicis

  1. Fita de projecte — reposar. Implementa ServeiVendes.reposar(titol: str, unitats: int) -> Llibre amb les tres fases: valida que el llibre existeix i que unitats >= 1 (decideix tu quin error llança el segon cas i documenta-ho), suma estoc, persisteix i registra-ho al log amb nivell INFO. Verificació: reposar 5 Odissees deixa estoc 9 a cataleg.json després de reiniciar.
  2. Fita de projecte — tancament_de_caixa. Implementa tancament_de_caixa(data: str) -> float: total venut en aquesta data segons vendes.csv. Pista: reutilitza el teu generador llegir_vendes de M8 o el csv.DictReader de M6. Verificació: després de vendre 18.67 i 10.35 avui, el tancament d'avui dona 29.02 i el d'ahir 0.0.
  3. Fita de projecte — el teu H4 complet. Implementa la resta de la teva interfície per contracte (les 5 rutes restants a A; llistat, detall i venda a B). Verificació: els criteris RF5 de 12-01, un a un, amb curl o navegador.

Solucions

  1. Orientativa — el cos segueix el patró exacte de vendre: fase 1, if titol not in self._cataleg: raise LlibreNoTrobatError(titol) i if unitats < 1: raise ValueError(...) (aquí ValueError és defensable: unitats negatives no són un cas de negoci sinó un error de programació; si has preferit crear ReposicioInvalidaError, també és correcte — l'important és que ho hagis decidit i anotat); fase 3, llibre.estoc += unitats, guardar, logger.info(...), return llibre.
  2. Orientativa: filtra files amb fila["data"] == data, acumula float(fila["quantia"]) i retorna round(total, 2). Si el CSV encara no existeix, retornar 0.0 és raonable (caixa buida) — una altra decisió per a DECISIONS.md. Trampa esperable: oblidar el float() i concatenar strings.
  3. Sense solució única. Autocomprovació de l'itinerari A: curl -X POST /api/llibres amb un títol ja existent ha de retornar 409, no 500 ni 200 — si retorna 500, el teu endpoint no està capturant/traduint l'error de domini; revisa els errorhandler.

Conclusió

Papyrus Online ja no és un pla: és codi que ven, persisteix, serveix i informa. Has recorregut les sis fites amb el patró que importa — validar, calcular, mutar — vist en detall a l'única peça resolta, el ServeiVendes, i replicat per tu en tota la resta. T'emportes a més dues eines que valen per a qualsevol projecte futur: la taula de trampes d'integració (símptoma → causa) i el protocol d'encallada en cinc passos. Però "em funciona a mi, avui, a la meva màquina" no és un sistema acabat: és un sistema no verificat. La propera lliçó converteix els criteris d'acceptació de 12-01 en una suite de tests que demostri — a tu i a qualsevol — que Papyrus Online fa el que promet, i t'ensenya a depurar a través de les capes quan algun test digui que no.

Curs de Programació en Python

Mòdul 1: Introducció a Python

Mòdul 2: Estructures de Control

Mòdul 3: Funcions i Mòduls

Mòdul 4: Estructures de Dades

Mòdul 5: Programació Orientada a Objectes

Mòdul 6: Gestió de Fitxers

Mòdul 7: Gestió d'Errors i Excepcions

Mòdul 8: Temes Avançats

Mòdul 9: Proves i Depuració

Mòdul 10: Desenvolupament Web amb Python

Mòdul 11: Ciència de Dades amb Python

Mòdul 12: Projecte Final

© Copyright 2026. Tots els drets reservats