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
- H1 — Domini: reutilitzar amb criteri
- H2 — Serveis:
ServeiVendes, la peça resolta - H3 — Persistència: repositoris amb guardat atòmic
- H4 — Interfície: un exemple complet, la resta per contracte
- H5 — Informe: esquelet amb sortides esperades
- H6 — Poliment: logging, README, requirements
- Treballar en passos petits (i la menció honesta a git)
- Trampes d'integració típiques
- 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 CupoInvalidErrorSi 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 vendaTres coses que fan professional aquest codi i que has de conservar en el teu:
- Les tres fases: validar → calcular → mutar. Si
CupoInvalidErrorsalta 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_cupoens continua protegint. - El log explica la història: cada rebuig és un
WARNINGamb 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), 201Mira 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.basicConfigcap adades/papyrus.log, format amb data i nivell, com a 07-05) i verifica RF7: provoca un rebuig per estoc i busca el seuWARNINGal fitxer. requirements.txt:pip freeze > requirements.txtdes 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):
- 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. - 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.
- Interroga el log:
papyrus.logexplica 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). pdba 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".- 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
ServeiVendessense 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 areposar, 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
logginga cada mòdul. Es configura una vegada a l'arrencada; els mòduls només fangetLogger("papyrus"). DosbasicConfig= 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.csvexisteixen perquè sàpigues què esperar. Amb dades inventades, un resultat estrany no saps si és bug o dada.
Exercicis
- Fita de projecte —
reposar. ImplementaServeiVendes.reposar(titol: str, unitats: int) -> Llibreamb les tres fases: valida que el llibre existeix i queunitats >= 1(decideix tu quin error llança el segon cas i documenta-ho), suma estoc, persisteix i registra-ho al log amb nivellINFO. Verificació: reposar 5 Odissees deixa estoc 9 acataleg.jsondesprés de reiniciar. - Fita de projecte —
tancament_de_caixa. Implementatancament_de_caixa(data: str) -> float: total venut en aquesta data segonsvendes.csv. Pista: reutilitza el teu generadorllegir_vendesde M8 o elcsv.DictReaderde 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. - 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
curlo navegador.
Solucions
- Orientativa — el cos segueix el patró exacte de
vendre: fase 1,if titol not in self._cataleg: raise LlibreNoTrobatError(titol)iif 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 crearReposicioInvalidaError, també és correcte — l'important és que ho hagis decidit i anotat); fase 3,llibre.estoc += unitats,guardar,logger.info(...),return llibre. - Orientativa: filtra files amb
fila["data"] == data, acumulafloat(fila["quantia"])i retornaround(total, 2). Si el CSV encara no existeix, retornar0.0és raonable (caixa buida) — una altra decisió per aDECISIONS.md. Trampa esperable: oblidar elfloat()i concatenar strings. - Sense solució única. Autocomprovació de l'itinerari A:
curl -X POST /api/llibresamb 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 elserrorhandler.
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
- Introducció a Python
- Configuració de l'Entorn de Desenvolupament
- Sintaxi de Python i Tipus de Dades Bàsics
- Variables i Constants
- Entrada i Sortida Bàsica
- Entorns Virtuals i Gestió de Paquets
Mòdul 2: Estructures de Control
Mòdul 3: Funcions i Mòduls
- Definició de Funcions
- Arguments de Funció
- Funcions Lambda
- Mòduls i Paquets
- Visió General de la Biblioteca Estàndard
Mòdul 4: Estructures de Dades
Mòdul 5: Programació Orientada a Objectes
Mòdul 6: Gestió de Fitxers
- Lectura i Escriptura de Fitxers
- Treball amb Fitxers CSV
- Gestió de Dades JSON
- Operacions amb Fitxers i Directoris
Mòdul 7: Gestió d'Errors i Excepcions
- Introducció a les Excepcions
- Gestió d'Excepcions
- Llançament d'Excepcions
- Excepcions Personalitzades
- Bones Pràctiques i Registre d'Errors amb logging
Mòdul 8: Temes Avançats
- Anotacions de Tipus (type hints)
- Decoradors
- Generadors
- Gestors de Context
- Concurrència: Fils i Processos
- Asyncio per a la Programació Asíncrona
Mòdul 9: Proves i Depuració
- Introducció a les Proves
- Proves Unitàries amb unittest
- Proves amb pytest
- Desenvolupament Guiat per Proves
- Tècniques de Depuració
- Ús de pdb per a la Depuració
Mòdul 10: Desenvolupament Web amb Python
- Introducció al Desenvolupament Web
- Fonaments del Framework Flask
- Construcció d'APIs REST amb Flask
- Introducció a Django
- Construcció d'Aplicacions Web amb Django
Mòdul 11: Ciència de Dades amb Python
- Introducció a la Ciència de Dades
- NumPy per a la Computació Numèrica
- Pandas per a la Manipulació de Dades
- Matplotlib per a la Visualització de Dades
- Introducció a l'Aprenentatge Automàtic amb scikit-learn
