T8.6 - D6: the material list uploads the way the location list does
CR-013 accepted free text because the master workbook never arrived; the
Aug 18 call was the CR-005 call again - build the upload path now.
THE component, extracted: T5.4's paste-or-file machinery (file read in the
browser, ONE parser on the server; dry-run check; a report naming every
rejected row with its source line; an editable list that deactivates rather
than deletes) moved from the location-specific functions into
html/wp-list-import.js. The location list and the new material list are both
instances of it - the done-when's "against the same component, not beside it"
made literally true. The loc* names survive as thin delegates because row
handlers, step entry and the probes call them; locations_check re-pointed its
fetch-count assertion to where the fetches now live and still demands every
read and write reach the server.
The material list itself: description, unit, optional code - one new table
(Alembic a1b8c6d4e2f9, additive), GET/import/POST/PATCH routes on the CR-005
pattern, deactivate-never-delete, reactivation reuses the same row so nothing
referencing it orphans. The sample rows are obviously fake (SAMPLE-EMT-075).
NO inventory, price, stock or warehouse field anywhere - the probe walks the
model's columns by regex. The wizard hosts it on step 11 beside the location
list, optional by design: a project with no list still raises free-text
requests (T8.5 wires that).
Parser bug caught by the probe's first run: strip(',;') ate a LEADING comma,
so ',FT' - an empty description - was accepted as a material named FT.
rstrip only, now; the empty first column is rejected with its line number.
Verification (each probe run alone): NEW tests/materials_check.py 17/17.
Regression: locations_check 58/58 through the shared component.
Items: D6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
"""per-project material list (D6 / T8.6)
|
||||
|
||||
CR-013 was written to accept free text because Nate's spreadsheet and the
|
||||
master material workbook had not been supplied - and they still have not.
|
||||
The Aug 18 call was the same one made for locations at CR-005: build the
|
||||
upload path now. One row per line item a request can pick from: description,
|
||||
unit, an optional code. Deliberately NO inventory level, price or warehouse
|
||||
id - a project-scoped uploaded list is not the deferred parts catalog.
|
||||
|
||||
Additive only: a new table, no change to any existing one.
|
||||
|
||||
Revision ID: a1b8c6d4e2f9
|
||||
Revises: f3a9d2c1e8b7
|
||||
Create Date: 2026-08-19 15:40:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a1b8c6d4e2f9'
|
||||
down_revision = 'f3a9d2c1e8b7'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'material_items',
|
||||
sa.Column('id', sa.String(length=40), nullable=False),
|
||||
sa.Column('project_id', sa.String(length=40), nullable=False),
|
||||
sa.Column('code', sa.String(length=80), nullable=False, server_default=''),
|
||||
sa.Column('description', sa.String(length=300), nullable=False, server_default=''),
|
||||
sa.Column('unit', sa.String(length=20), nullable=False, server_default=''),
|
||||
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.text('1')),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_material_items_project_id', 'material_items', ['project_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_material_items_project_id', table_name='material_items')
|
||||
op.drop_table('material_items')
|
||||
165
server/app.py
165
server/app.py
@@ -2436,6 +2436,24 @@ def import_locations(project_id: str, body: LocationImportIn,
|
||||
return result
|
||||
|
||||
|
||||
class MaterialIn(BaseModel):
|
||||
description: str = ""
|
||||
unit: str = ""
|
||||
code: str = ""
|
||||
|
||||
|
||||
class MaterialPatchIn(BaseModel):
|
||||
description: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
code: Optional[str] = None
|
||||
active: Optional[bool] = None
|
||||
|
||||
|
||||
class MaterialImportIn(BaseModel):
|
||||
text: str = ""
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class LocationIn(BaseModel):
|
||||
level: str = "building"
|
||||
parent_id: Optional[str] = None
|
||||
@@ -2873,6 +2891,153 @@ def delete_wp_file(file_id: str, user: models.User = Depends(auth.get_current_us
|
||||
return {"deleted": file_id, "used": used, "ceiling": FILE_PROJECT_CEILING}
|
||||
|
||||
|
||||
# ── Project material list (D6 / T8.6) - the CR-005 pattern, for materials ─────
|
||||
def parse_material_rows(text: str):
|
||||
"""description[,unit[,code]] per row - comma, semicolon or tab separated, a
|
||||
paste straight out of a spreadsheet. A header row is ignored. Rejections come
|
||||
back with the SOURCE line number: an import that says '42 rows' over a file
|
||||
with 50 has lost eight and told nobody."""
|
||||
rows, rejected = [], []
|
||||
header_words = ("description", "desc", "item", "material")
|
||||
for i, raw in enumerate((text or "").split(chr(10)), start=1):
|
||||
# rstrip only: a LEADING separator means the first column is empty, and
|
||||
# the first column is the description - eating it would accept ",FT" as
|
||||
# a material named FT (found by the probe on the first run).
|
||||
line = raw.strip().rstrip(",;")
|
||||
if not line:
|
||||
continue
|
||||
parts = [p.strip() for p in re.split(r"[,;\t]", line)]
|
||||
if i == 1 and parts and parts[0].lower() in header_words:
|
||||
continue
|
||||
parts = [p for p in parts]
|
||||
if not parts or not parts[0]:
|
||||
rejected.append({"line": i, "text": raw.strip()[:120],
|
||||
"reason": "no description in the first column"})
|
||||
continue
|
||||
if len(parts) > 3:
|
||||
rejected.append({"line": i, "text": raw.strip()[:120],
|
||||
"reason": "more than three columns - description, unit, code is the whole shape"})
|
||||
continue
|
||||
rows.append((i, parts))
|
||||
return rows, rejected
|
||||
|
||||
|
||||
def material_key(desc: str, code: str) -> str:
|
||||
return (code or "").strip().lower() or location_slug(desc).lower()
|
||||
|
||||
|
||||
@app.get("/api/projects/{project_id}/materials")
|
||||
def list_materials(project_id: str, include_inactive: bool = Query(False),
|
||||
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
require_project_access(db, user, project_id)
|
||||
stmt = select(models.MaterialItem).where(models.MaterialItem.project_id == project_id)
|
||||
if not include_inactive:
|
||||
stmt = stmt.where(models.MaterialItem.active.is_(True))
|
||||
rows = db.scalars(stmt.order_by(models.MaterialItem.sort, models.MaterialItem.description)).all()
|
||||
return {"items": [r.to_dict() for r in rows]}
|
||||
|
||||
|
||||
@app.post("/api/projects/{project_id}/materials/import")
|
||||
def import_materials(project_id: str, body: MaterialImportIn,
|
||||
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
if not db.get(models.Project, project_id):
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_project_access(db, user, project_id)
|
||||
require_project_writable(db, user, project_id, "The material list cannot be changed")
|
||||
rows, rejected = parse_material_rows(body.text)
|
||||
existing = {material_key(r.description, r.code): r for r in db.scalars(
|
||||
select(models.MaterialItem).where(models.MaterialItem.project_id == project_id)).all()}
|
||||
created, duplicates, reactivated = [], [], []
|
||||
seen_in_file = {}
|
||||
next_sort = max((r.sort for r in existing.values()), default=0)
|
||||
for line_no, parts in rows:
|
||||
desc = parts[0][:300]
|
||||
unit = (parts[1] if len(parts) > 1 else "")[:20].upper()
|
||||
code = (parts[2] if len(parts) > 2 else "")[:80]
|
||||
key = material_key(desc, code)
|
||||
if key in seen_in_file:
|
||||
duplicates.append({"line": line_no, "text": desc,
|
||||
"reason": "already on line %d of this import" % seen_in_file[key]})
|
||||
continue
|
||||
seen_in_file[key] = line_no
|
||||
if key in existing:
|
||||
row = existing[key]
|
||||
if not row.active:
|
||||
if not body.dry_run:
|
||||
row.active = True
|
||||
reactivated.append({"text": desc})
|
||||
else:
|
||||
duplicates.append({"line": line_no, "text": desc,
|
||||
"reason": "already on this project"})
|
||||
continue
|
||||
next_sort += 1
|
||||
created.append({"description": desc, "unit": unit, "code": code})
|
||||
if not body.dry_run:
|
||||
item = models.MaterialItem(id=gen_id("mat"), project_id=project_id,
|
||||
code=code, description=desc, unit=unit,
|
||||
active=True, sort=next_sort)
|
||||
db.add(item)
|
||||
existing[key] = item
|
||||
if not body.dry_run:
|
||||
log_event(db, user, "materials_imported", "project", project_id,
|
||||
project_id=project_id, summary="material list",
|
||||
detail={"created": len(created), "rejected": len(rejected)})
|
||||
db.commit()
|
||||
return {"read": len(rows) + len(rejected), "created": created,
|
||||
"rejected": rejected, "duplicates": duplicates,
|
||||
"reactivated": reactivated, "dry_run": body.dry_run}
|
||||
|
||||
|
||||
@app.post("/api/projects/{project_id}/materials")
|
||||
def add_material(project_id: str, body: MaterialIn,
|
||||
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
if not db.get(models.Project, project_id):
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_project_access(db, user, project_id)
|
||||
require_project_writable(db, user, project_id, "The material list cannot be changed")
|
||||
desc = (body.description or "").strip()
|
||||
if not desc:
|
||||
raise HTTPException(status_code=400, detail="A description is required.")
|
||||
key = material_key(desc, body.code)
|
||||
clash = [r for r in db.scalars(select(models.MaterialItem)
|
||||
.where(models.MaterialItem.project_id == project_id)).all()
|
||||
if material_key(r.description, r.code) == key]
|
||||
if clash:
|
||||
raise HTTPException(status_code=409, detail="That material is already on this project.")
|
||||
next_sort = (db.scalar(select(func.coalesce(func.max(models.MaterialItem.sort), 0))
|
||||
.where(models.MaterialItem.project_id == project_id)) or 0) + 1
|
||||
item = models.MaterialItem(id=gen_id("mat"), project_id=project_id,
|
||||
code=(body.code or "").strip()[:80],
|
||||
description=desc[:300],
|
||||
unit=(body.unit or "").strip()[:20].upper(),
|
||||
active=True, sort=next_sort)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@app.patch("/api/projects/{project_id}/materials/{item_id}")
|
||||
def patch_material(project_id: str, item_id: str, body: MaterialPatchIn,
|
||||
user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
row = db.get(models.MaterialItem, item_id)
|
||||
if not row or row.project_id != project_id:
|
||||
raise HTTPException(status_code=404, detail="Material not found")
|
||||
require_project_access(db, user, project_id)
|
||||
require_project_writable(db, user, project_id, "The material list cannot be changed")
|
||||
if body.description is not None:
|
||||
row.description = body.description.strip()[:300]
|
||||
if body.unit is not None:
|
||||
row.unit = body.unit.strip()[:20].upper()
|
||||
if body.code is not None:
|
||||
row.code = body.code.strip()[:80]
|
||||
if body.active is not None:
|
||||
# Deactivate, never delete - a request already referencing the line must
|
||||
# keep rendering it (the CR-005 rule, applied to materials).
|
||||
row.active = bool(body.active)
|
||||
db.commit()
|
||||
return row.to_dict()
|
||||
|
||||
|
||||
@app.get("/api/audit")
|
||||
def list_audit(
|
||||
entity_type: Optional[str] = Query(None),
|
||||
|
||||
@@ -347,6 +347,32 @@ class AppSetting(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class MaterialItem(Base):
|
||||
"""One line of a project's material list - D6 / T8.6, the CR-005 call made
|
||||
again: build the upload path now rather than wait for the master workbook.
|
||||
Deliberately small: description, unit, an optional code. NO inventory level,
|
||||
NO price, NO warehouse id - a project-scoped list the project uploaded is
|
||||
not the deferred parts catalog, and the moment a stock count appears here it
|
||||
has crossed the line IMPLEMENTATION.md section 7 draws. `active` rather than
|
||||
delete, same as everything else: a request already referencing a line must
|
||||
keep rendering it."""
|
||||
__tablename__ = "material_items"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||
project_id: Mapped[str] = mapped_column(String(40), index=True)
|
||||
code: Mapped[str] = mapped_column(String(80), default="")
|
||||
description: Mapped[str] = mapped_column(String(300), default="")
|
||||
unit: Mapped[str] = mapped_column(String(20), default="")
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
sort: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"id": self.id, "project_id": self.project_id, "code": self.code,
|
||||
"description": self.description, "unit": self.unit,
|
||||
"active": self.active, "sort": self.sort}
|
||||
|
||||
|
||||
class WpFile(Base):
|
||||
"""CR-007 / D8: a drawing uploaded onto a work package. The BYTES live here,
|
||||
in the same database as everything else - settled Aug 18: a backup that
|
||||
|
||||
Reference in New Issue
Block a user