docs(fusion_plating): spec - WO grouping by recipe structure + combined multi-part CoC
Group SO lines into one fp.job per distinct plating process (identical recipe step structure) instead of one WO per line; make the Certificate of Conformance multi-part via a new fp.certificate.part child model + CoC parts-table loop + migration backfill. Grounded in a read-only entech audit: 13 WOs -> 4 on real orders; per-part recipe clones are structurally identical (same node_type + kind_code + name sequence). cloned_from_id/process_type_id are empty on existing data, so grouping keys off the step structure. Phase 1 (this spec): grouping + combined cert + report + traveller + migration. Phase 2 (deferred): per-part thickness + per-part stickers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
# WO Grouping by Recipe + Combined Multi-Part Certificate
|
||||
|
||||
**Date:** 2026-06-03
|
||||
**Module(s):** `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports`
|
||||
**Author:** Gurpreet (Nexa Systems Inc.)
|
||||
**Status:** Approved — ready for implementation plan
|
||||
|
||||
## Summary
|
||||
|
||||
Today a confirmed sale order with N plating lines creates N work orders
|
||||
(`fp.job` / "WO-NNN"), even when every line runs the same plating
|
||||
process. The shop wants **one work order per recipe** — different parts
|
||||
that go through the same process should ride one traveller and one
|
||||
physical batch, splitting into separate WOs **only when the process
|
||||
actually differs**.
|
||||
|
||||
The blocker is the **Certificate of Conformance**: a `fp.job` carries a
|
||||
single `part_catalog_id` / `customer_spec_id`, and the CoC PDF renders
|
||||
exactly one part row. Collapsing four parts onto one WO would certify
|
||||
only the first and silently ship the other three uncertified — the exact
|
||||
"silent mis-attestation" the 2026-05-13 sticker spec was built to
|
||||
prevent.
|
||||
|
||||
This spec resolves that by making the **certificate multi-part**: one
|
||||
combined CoC per WO that lists every part in a table, each with its own
|
||||
part #, spec, serial, and quantities. The grouping change and the
|
||||
multi-part cert ship together because neither is safe alone.
|
||||
|
||||
## Audit findings (live entech, db=admin, read-only, 2026-06-03)
|
||||
|
||||
Pulled the real numbers before designing — they overturned the obvious
|
||||
"group by `recipe_id`" approach.
|
||||
|
||||
| Order | Lines | WOs today | Distinct recipes | WOs after |
|
||||
|-------|-------|-----------|------------------|-----------|
|
||||
| SO-30092 | 2 | 2 | 2 (`ENP ALUM BASIC HP`) | **1** |
|
||||
| SO-30083 | 4 | 4 | 4 (`ENP-STEEL-MP-BASIC`) | **1** |
|
||||
| SO-30079 | 4 | 4 | 4 (2 parts × 2 lines) | **1** |
|
||||
| SO-30071 | 3 | 3 | 3 (`ENP-STEEL-MP-BASIC`) | **1** |
|
||||
|
||||
- 23 confirmed SOs total; 4 are multi-plating-line. 13 plating lines
|
||||
across those 4 orders collapse from **13 WOs → 4 WOs**.
|
||||
- **Root cause:** every part gets its own *clone* of a base recipe,
|
||||
renamed `<BASE> — <part#>` (the ` — <suffix>` is stamped by
|
||||
`_clone_subtree` in `fp_part_composer_controller.py`). So each line
|
||||
resolves to a *distinct* `fusion.plating.process.node` record →
|
||||
grouping by `recipe_id` merges **nothing**.
|
||||
- The clones are **byte-identical in structure** — 9 (or 11) descendant
|
||||
nodes, same `node_type` + `kind_id.code` + name in the same order.
|
||||
Verified across all 4 orders. So merging is **faithful**: every part
|
||||
follows the identical steps.
|
||||
- `process_type_id` is **empty** on all of them → not a usable signal.
|
||||
- `cloned_from_id` exists as a field but is **empty on all 13** lines →
|
||||
not usable for existing data without a backfill.
|
||||
- **13 existing `fp.certificate` rows** → migration size.
|
||||
|
||||
**Conclusion:** the only signals that work on real data are *identical
|
||||
step structure* and *shared base-name prefix*. We group by **identical
|
||||
step structure** (truthful, naming-independent, no backfill).
|
||||
|
||||
## Locked decisions (from brainstorming, 2026-06-03)
|
||||
|
||||
| Q | Decision |
|
||||
|---|----------|
|
||||
| One WO covers many parts — how do certs work? | **One combined cert** listing every part in a table. |
|
||||
| How much varies between parts in one order? | **Varies by order** → build the full per-part model (handles uniform and per-part-divergent orders). |
|
||||
| Is "same recipe" one shared record or per-part copies? | **Audited:** per-part clones, structurally identical. Group by structure, not record id. |
|
||||
| Grouping signal? | **Identical step structure** (recipe structural signature). |
|
||||
| Two recipes "the same"? | Same `node_type` + `kind_id.code` + name sequence across descendant steps. Numeric targets (thickness/temp/time) are **excluded** — they're per-part attestation data on the cert, not a batch splitter. |
|
||||
|
||||
## Goals / non-goals
|
||||
|
||||
**Goals**
|
||||
- One WO per distinct plating process; same-process parts share one WO.
|
||||
- A single combined CoC per WO listing each part's own identity + spec +
|
||||
quantities.
|
||||
- No silent loss of any part's certification when parts share a WO.
|
||||
- Per-part masking/bake differences split the WO (never silently merge).
|
||||
- Existing WOs and certs keep working unchanged; the 13 existing certs
|
||||
render identically after migration.
|
||||
|
||||
**Non-goals**
|
||||
- Re-grouping already-created WOs (only new confirmations regroup).
|
||||
- Removing the per-part recipe-cloning mechanism (root-cause fix to the
|
||||
Part Composer — separate, larger, riskier; out of scope).
|
||||
- Per-part thickness rendering, per-part box stickers, per-part issue
|
||||
gate → **Phase 2** (see below).
|
||||
- Per-physical-box serial tracking (unchanged from prior specs).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Phase 1 — compliance-safe MVP
|
||||
|
||||
#### Change 1 — Grouping by recipe structural signature
|
||||
|
||||
File: `fusion_plating_jobs/models/sale_order.py`, method
|
||||
`_fp_auto_create_job` (the `groups` block around line 439-470).
|
||||
|
||||
Replace the 5-tuple key `(recipe, part, spec, thickness, serial)` with a
|
||||
**structural signature** key. New helpers on `sale.order`:
|
||||
|
||||
```python
|
||||
def _fp_recipe_signature(self, recipe):
|
||||
"""Hashable structural signature of a recipe's step tree.
|
||||
|
||||
Two recipes with the same signature have identical processing
|
||||
steps and can share one work order. Excludes the recipe ROOT name
|
||||
(carries the per-part ' — <part#>' suffix) and all numeric targets
|
||||
(thickness/temp/time/voltage) — those are per-part attestation
|
||||
data captured on the cert, not a reason to split the batch.
|
||||
Returns None for a missing recipe.
|
||||
"""
|
||||
if not recipe:
|
||||
return None
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
kids = Node.search(
|
||||
[('id', 'child_of', recipe.id),
|
||||
('node_type', 'in', ('sub_process', 'operation', 'step'))],
|
||||
order='parent_path, sequence')
|
||||
return tuple(
|
||||
(k.node_type,
|
||||
(k.kind_id.code if k.kind_id else '') or '',
|
||||
(k.name or '').strip().lower())
|
||||
for k in kids)
|
||||
|
||||
def _fp_line_express_signature(self, line):
|
||||
"""Per-line Express override flags that change physical processing
|
||||
(masking on/off, bake setpoint/duration, etc.). Lines that differ
|
||||
here must NOT merge even when the recipe structure matches, or the
|
||||
shared WO would silently drop one part's masking/bake.
|
||||
|
||||
The exact field set is enumerated from sale.order.line's Express
|
||||
Orders fields at implementation time (x_fc_masking_enabled + the
|
||||
bake override fields); all reads are field-guarded.
|
||||
"""
|
||||
F = line._fields
|
||||
bits = []
|
||||
for fname in self._FP_EXPRESS_OVERRIDE_FIELDS:
|
||||
if fname in F:
|
||||
bits.append((fname, line[fname]))
|
||||
return tuple(bits)
|
||||
|
||||
def _fp_line_group_key(self, line):
|
||||
recipe = self._fp_resolve_recipe_for_line(line)
|
||||
if not recipe:
|
||||
return ('no_recipe', line.id) # never merges
|
||||
return ('recipe',
|
||||
self._fp_recipe_signature(recipe),
|
||||
self._fp_line_express_signature(line))
|
||||
```
|
||||
|
||||
The grouping loop becomes:
|
||||
|
||||
```python
|
||||
groups = {}
|
||||
for line in plating_lines:
|
||||
key = self._fp_line_group_key(line)
|
||||
groups[key] = groups.get(key, self.env['sale.order.line']) | line
|
||||
```
|
||||
|
||||
Everything downstream of `groups` is unchanged: `ordered_keys` still
|
||||
sorts by min line sequence, `n_groups` still drives single-vs-suffixed
|
||||
WO naming (`WO-<parent>` vs `WO-<parent>-NN`), and the per-group job
|
||||
create loop already sums qty, carries `sale_order_line_ids`, and copies
|
||||
SO header fields.
|
||||
|
||||
**Representative recipe:** the WO's `recipe_id` is the first line's
|
||||
recipe in the group. Because every recipe in the group is structurally
|
||||
identical, step generation (`fp.job.action_confirm` →
|
||||
`_generate_steps_from_recipe`) produces the correct steps for all parts.
|
||||
|
||||
**Job singular fields:** `part_catalog_id` / `customer_spec_id` keep
|
||||
pointing at the first line's values (display + back-compat). The
|
||||
per-part truth lives in `sale_order_line_ids` and the cert part-lines.
|
||||
|
||||
#### Change 2 — `fp.certificate.part` (new child model)
|
||||
|
||||
File: `fusion_plating_certificates/models/fp_certificate_part.py` (new).
|
||||
|
||||
```python
|
||||
class FpCertificatePart(models.Model):
|
||||
_name = 'fp.certificate.part'
|
||||
_description = 'Certificate Part Line'
|
||||
_order = 'certificate_id, sequence, id'
|
||||
|
||||
certificate_id = fields.Many2one(
|
||||
'fp.certificate', required=True, ondelete='cascade', index=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
sale_order_line_id = fields.Many2one('sale.order.line') # traceability
|
||||
part_catalog_id = fields.Many2one('fp.part.catalog')
|
||||
part_number = fields.Char() # snapshot
|
||||
part_name = fields.Char() # snapshot of catalog .name
|
||||
description = fields.Char() # customer-facing description snapshot
|
||||
serial = fields.Char() # comma-joined serial names snapshot
|
||||
customer_spec_id = fields.Many2one('fusion.plating.customer.spec')
|
||||
spec_reference = fields.Char() # snapshot 'CODE Rev X'
|
||||
quantity_shipped = fields.Integer()
|
||||
nc_quantity = fields.Integer()
|
||||
# Phase 2: thickness_reading_ids (inverse certificate_part_id)
|
||||
```
|
||||
|
||||
On `fp.certificate`:
|
||||
|
||||
```python
|
||||
part_line_ids = fields.One2many(
|
||||
'fp.certificate.part', 'certificate_id', string='Parts')
|
||||
```
|
||||
|
||||
Views: add an editable `part_line_ids` list to the certificate form
|
||||
(so the issuer can review/adjust before issuing). ACL rows for
|
||||
`fp.certificate.part` mirror `fp.certificate`'s groups (operator read +
|
||||
manager write, matching the existing cert ACL).
|
||||
|
||||
#### Change 3 — `_fp_create_certificates` fills part-lines
|
||||
|
||||
File: `fusion_plating_jobs/models/fp_job.py` (method around line 2716).
|
||||
|
||||
- **Requirement union** — `_resolve_required_cert_types` currently reads
|
||||
the *first* part's `certificate_requirement`. Walk **all** plating
|
||||
lines on the job; union each part's wanted set (part-level override
|
||||
else partner inherit). Recipe suppression + CoC/thickness bundling are
|
||||
unchanged (uniform — one recipe per WO).
|
||||
- **Cert create** — still one cert per resulting type. Cert-level fields
|
||||
(po_number, customer_job_no, process_description = base recipe name,
|
||||
certified_by_id, contact, entech_wo_number, sale_order_id, x_fc_job_id)
|
||||
unchanged. **Legacy singular fields** (part_number, spec_reference,
|
||||
quantity_shipped, nc_quantity) keep being set from the **first** line
|
||||
for back-compat.
|
||||
- **Part-lines** — build one `fp.certificate.part` per plating line on
|
||||
the job (`_fp_cert_source_lines()` = `sale_order_line_ids` filtered to
|
||||
lines with a part):
|
||||
|
||||
```python
|
||||
seq = 10
|
||||
part_cmds = []
|
||||
for sol in self._fp_cert_source_lines():
|
||||
part = sol.x_fc_part_catalog_id
|
||||
spec = sol.x_fc_customer_spec_id if 'x_fc_customer_spec_id' in sol._fields else False
|
||||
part_cmds.append((0, 0, {
|
||||
'sequence': seq,
|
||||
'sale_order_line_id': sol.id,
|
||||
'part_catalog_id': part.id if part else False,
|
||||
'part_number': (part.part_number if part else '') or '',
|
||||
'part_name': (part.name if part else '') or '',
|
||||
'description': sol.fp_customer_description()
|
||||
if hasattr(sol, 'fp_customer_description') else (sol.name or ''),
|
||||
'serial': ', '.join(sol.x_fc_serial_ids.mapped('name'))
|
||||
if 'x_fc_serial_ids' in sol._fields else '',
|
||||
'customer_spec_id': spec.id if spec else False,
|
||||
'spec_reference': self._fp_format_spec_ref(spec),
|
||||
'quantity_shipped': int(sol.product_uom_qty or 0),
|
||||
'nc_quantity': 0,
|
||||
}))
|
||||
seq += 10
|
||||
vals['part_line_ids'] = part_cmds
|
||||
```
|
||||
|
||||
**Per-part quantities:** `quantity_shipped` defaults to the **line**
|
||||
qty (naturally per-part). `nc_quantity` defaults to **0** — scrap /
|
||||
visual rejects are tracked at job level only, not per part, so we do not
|
||||
auto-split them; the issuer edits per-part NC at issue if needed. The
|
||||
job-level NC total remains on the cert's legacy `nc_quantity` field.
|
||||
|
||||
**Idempotency:** the existing per-type idempotency guard is unchanged;
|
||||
re-running `_fp_create_certificates` does not duplicate certs or lines.
|
||||
|
||||
#### Change 4 — CoC report renders the parts table as a loop
|
||||
|
||||
File: `fusion_plating_reports/report/report_coc.xml` (tbody at line
|
||||
297-321).
|
||||
|
||||
```xml
|
||||
<tbody>
|
||||
<t t-foreach="doc.part_line_ids" t-as="pl">
|
||||
<tr>
|
||||
<td class="text-center" style="line-height: 1.3;">
|
||||
<div><t t-esc="pl.part_number or '-'"/></div>
|
||||
<div><t t-esc="pl.part_name or '-'"/></div>
|
||||
<div><t t-esc="pl.serial or '-'"/></div>
|
||||
</td>
|
||||
<td>
|
||||
<t t-esc="pl.description or doc.process_description or ''"/>
|
||||
<t t-if="pl.spec_reference"><br/><em t-esc="pl.spec_reference"/></t>
|
||||
</td>
|
||||
<td class="text-center"><t t-esc="doc.po_number or '-'"/></td>
|
||||
<td class="text-center"><t t-esc="pl.quantity_shipped or 0"/></td>
|
||||
<td class="text-center"><t t-esc="pl.nc_quantity or 0"/></td>
|
||||
<td class="text-center"><t t-esc="doc.customer_job_no or '-'"/></td>
|
||||
</tr>
|
||||
</t>
|
||||
<!-- Defensive fallback: legacy cert with no part-lines (should not
|
||||
occur post-migration) renders the old single row. -->
|
||||
<tr t-if="not doc.part_line_ids">
|
||||
... existing _fp_resolve_part_identity() / _fp_resolve_customer_facing_description() row ...
|
||||
</tr>
|
||||
</tbody>
|
||||
```
|
||||
|
||||
Process / PO / Customer-Job columns: PO and Customer Job No. are SO-level
|
||||
(uniform), kept cert-level. The Process column shows each part's own
|
||||
customer-facing description + spec_reference (per 2026-05-28 policy).
|
||||
`page-break-inside: avoid` stays on each `<tr>` (per CLAUDE.md) so a part
|
||||
row never splits across a page.
|
||||
|
||||
#### Change 5 — Traveller lists all parts
|
||||
|
||||
File: `fusion_plating_jobs/report/report_fp_job_traveller.xml`.
|
||||
|
||||
The Item Information block today shows one part (`job.part_catalog_id`).
|
||||
Loop `job.sale_order_line_ids` (plating lines) so the operator sees every
|
||||
part in the batch with its qty. The routing/process table is unchanged
|
||||
(one shared recipe). Field reads stay defensively guarded.
|
||||
|
||||
#### Change 6 — Migration backfill
|
||||
|
||||
File: `fusion_plating_certificates/migrations/<new-version>/post-migrate.py`.
|
||||
|
||||
For each existing `fp.certificate` with no `part_line_ids`, create one
|
||||
part-line from its current singular fields so old certs render
|
||||
identically:
|
||||
|
||||
```python
|
||||
for cert in env['fp.certificate'].search([]):
|
||||
if cert.part_line_ids:
|
||||
continue
|
||||
pid = cert._fp_resolve_part_identity() # (number, name, serials)
|
||||
env['fp.certificate.part'].create({
|
||||
'certificate_id': cert.id, 'sequence': 10,
|
||||
'part_catalog_id': (cert.x_fc_job_id.part_catalog_id.id
|
||||
if cert.x_fc_job_id and cert.x_fc_job_id.part_catalog_id else False),
|
||||
'part_number': cert.part_number or (pid[0] or ''),
|
||||
'part_name': pid[1] or '',
|
||||
'description': cert._fp_resolve_customer_facing_description() or cert.process_description or '',
|
||||
'serial': pid[2] or '',
|
||||
'customer_spec_id': cert.customer_spec_id.id if cert.customer_spec_id else False,
|
||||
'spec_reference': cert.spec_reference or '',
|
||||
'quantity_shipped': cert.quantity_shipped or 0,
|
||||
'nc_quantity': cert.nc_quantity or 0,
|
||||
})
|
||||
```
|
||||
|
||||
Idempotent (skips certs that already have part-lines). 13 certs → 13
|
||||
single-part certs.
|
||||
|
||||
### Phase 2 — per-part refinement (separate plan)
|
||||
|
||||
- **Per-part thickness:** add `certificate_part_id` to
|
||||
`fp.thickness.reading`; associate readings + page-2 Fischerscope PDF
|
||||
merges per part; render a per-part thickness block under each part row;
|
||||
extend the `action_issue` thickness gate to require data on each part
|
||||
that needs thickness.
|
||||
- **Per-part box stickers:** today's consolidated "Multiple Line Items"
|
||||
sticker gains per-part detail / per-part labels.
|
||||
- **Cert form polish:** richer part-line editing UX.
|
||||
|
||||
Phase 2 is deferred and gets its own spec + plan once Phase 1 is live and
|
||||
validated on entech.
|
||||
|
||||
## Files touched (Phase 1)
|
||||
|
||||
| # | File | Change |
|
||||
|---|------|--------|
|
||||
| 1 | `fusion_plating_jobs/models/sale_order.py` | New `_fp_recipe_signature` / `_fp_line_express_signature` / `_fp_line_group_key`; rewrite the `groups` key; define `_FP_EXPRESS_OVERRIDE_FIELDS`. |
|
||||
| 2 | `fusion_plating_certificates/models/fp_certificate_part.py` | New model. |
|
||||
| 3 | `fusion_plating_certificates/models/fp_certificate.py` | `part_line_ids` O2M. |
|
||||
| 4 | `fusion_plating_certificates/models/__init__.py` | import new model. |
|
||||
| 5 | `fusion_plating_certificates/security/ir.model.access.csv` | ACL for `fp.certificate.part`. |
|
||||
| 6 | `fusion_plating_certificates/views/fp_certificate_views.xml` | Part-lines list on the cert form. |
|
||||
| 7 | `fusion_plating_jobs/models/fp_job.py` | `_resolve_required_cert_types` union over all parts; `_fp_cert_source_lines`; `_fp_format_spec_ref`; part-line build in `_fp_create_certificates`. |
|
||||
| 8 | `fusion_plating_reports/report/report_coc.xml` | tbody loop over `part_line_ids` + legacy fallback row. |
|
||||
| 9 | `fusion_plating_jobs/report/report_fp_job_traveller.xml` | Item Information loops all parts. |
|
||||
| 10 | `fusion_plating_certificates/migrations/<ver>/post-migrate.py` | Backfill one part-line per existing cert. |
|
||||
| 11 | `__manifest__.py` × (jobs, certificates, reports) | Version bumps. |
|
||||
|
||||
## Migration
|
||||
|
||||
- New `fp.certificate.part` table created on install/upgrade.
|
||||
- Post-migrate backfills the 13 existing certs (idempotent).
|
||||
- Existing jobs/WOs untouched — `_fp_auto_create_job`'s `if existing:
|
||||
return` guard means only **new** confirmations regroup.
|
||||
- No re-grouping tool for open orders in Phase 1 (out of scope; can be a
|
||||
one-off odoo-shell script later if the shop wants it).
|
||||
|
||||
## Testing
|
||||
|
||||
These modules require Enterprise deps and **cannot install on the local
|
||||
Community box** (`fusion_plating` shows `installed=0` on `modsdev`), so:
|
||||
|
||||
- **Static checks (local):** `pyflakes` on every changed `.py`; lxml
|
||||
parse on changed XML; `node --check` not needed (no JS).
|
||||
- **Unit (where installable):** the grouping helpers are pure functions
|
||||
of a recipe/line — `_fp_recipe_signature` returns equal tuples for two
|
||||
structurally-identical recipes and unequal for divergent ones;
|
||||
`_fp_line_group_key` merges same-structure lines and splits on
|
||||
differing express overrides.
|
||||
- **Live verification (entech via odoo shell, read-only first):**
|
||||
1. Re-run the audit signature on SO-30083/30079/30071/30092 →
|
||||
confirm each collapses to 1 group.
|
||||
2. On a **clone** (or a fresh test SO), confirm SO with 4 same-process
|
||||
lines → 1 WO carrying 4 `sale_order_line_ids`; SO with 2 different
|
||||
processes → 2 WOs.
|
||||
3. Confirm `_fp_create_certificates` produces one CoC with 4
|
||||
part-lines; render the CoC PDF → 4 part rows, correct per-part
|
||||
part#/serial/spec/qty.
|
||||
4. Render an existing (migrated) single-part cert → identical to
|
||||
before.
|
||||
5. A line with masking ON + a line with masking OFF, same recipe →
|
||||
**2** WOs (express-signature split).
|
||||
|
||||
## Edge cases & open questions
|
||||
|
||||
| Item | Decision |
|
||||
|------|----------|
|
||||
| No-recipe lines | Each its own WO (unchanged). |
|
||||
| Same recipe structure, different express masking/bake | **Split** (express signature in the key). |
|
||||
| Repeated same part across lines (SO-30079) | One cert part-line **per line** (not per distinct part) — each carries that line's serial/qty. |
|
||||
| Part with `certificate_requirement='none'` on a WO whose other part needs a CoC | Combined CoC is produced (union) and **lists all shipped parts** — the cert documents the physical shipment. (Confirmed 2026-06-03.) |
|
||||
| Per-part NC qty | Default 0 (job-level scrap not split per part); editable at issue. (Confirmed 2026-06-03.) |
|
||||
| Job `part_catalog_id` when multi-part | First line (display/back-compat). |
|
||||
| WO naming | `WO-<parent>` (1 group) / `WO-<parent>-NN` (N groups) — unchanged. |
|
||||
| Existing open multi-line SOs already split into WOs | Left as-is; no auto re-group. |
|
||||
|
||||
**Confirmed during review (2026-06-03):** the union-cert "list all
|
||||
shipped parts even if one part opted out" behaviour, and the "per-part
|
||||
NC defaults to 0, editable at issue" behaviour are both approved.
|
||||
Reference in New Issue
Block a user