Epic B2B-060: Product Catalog Intelligence
Goal: Enable mills to onboard their product catalog effortlessly via PDF datasheet upload, Excel import, or manual entry — with AI-powered extraction, product matching, and admin review.
Why: Mills won't type 30 fields into a form. They already have datasheets. We already have 8,919 processed documents and 4,246 catalog products in the Extractor pipeline. Connect the dots.
Dependencies:
- Extractor pipeline running at
localhost:8925(Flask + SQLite + Gemini Flash via OpenRouter) - Marketplace API at
localhost:8910(Django + PostgreSQL) - Product CRUD (B2B-051/052) ✅ DONE
Ticket Dependency Graph
B2B-060 (Sync Bridge)
↓
B2B-061 (Datasheet Upload Model + API)
↓
B2B-062 (AI Extraction Integration)
↓
B2B-063 (Product Matching Engine)
↓
B2B-064 (Admin Review Dashboard) ←── B2B-063
↓
B2B-065 (Mill Upload UI) ←── B2B-062
↓
B2B-066 (Clone Product) ←── (independent, B2B-051 only)
↓
B2B-067 (Excel Template Import) ←── B2B-061
↓
B2B-068 (Empty State + Onboarding Flow) ←── B2B-065, B2B-067
B2B-060: Extractor → Marketplace Sync Bridge
Priority: P0 Type: Backend Depends on: B2B-051 (Product CRUD) ✅
What
Management command that imports catalog_products and mills from the Extractor's SQLite DB into the Marketplace's PostgreSQL Product and Mill models. One-time bulk import + re-runnable (idempotent via fingerprint/extractor_id mapping).
Acceptance Criteria
- AC1:
python manage.py sync_extractor --source /home/claude/projects/paper-pdf-extractor/paper_data.dbimports catalog_products → Product model - AC2: Field mapping covers: name, category→paper_type, gsm, coating, color, width_mm, height_mm/length_mm, presentation→form, certifications (JSON), fiber_source→fiber_type, quality_grade, product_code, brand
- AC3: Mills from Extractor's
millstable are created/updated in Marketplace Mill model (matched by name, fuzzy) - AC4: Each imported Product links to its source mill
- AC5: Re-running the command does NOT create duplicates (uses
extractor_fingerprintfield on Product) - AC6: Command outputs summary: "Imported X new products, updated Y, skipped Z duplicates, created W mills"
- AC7: New field
extractor_fingerprint(CharField, nullable, unique, indexed) added to Product model
API Contract
N/A — management command only.
Playwright Test Expectations
N/A — backend only. Verify via:
python manage.py sync_extractor --source /path/to/paper_data.db --dry-run
# Outputs: "Would import X products, create Y mills"
python manage.py sync_extractor --source /path/to/paper_data.db
# Outputs: "Imported X products..."
python manage.py sync_extractor --source /path/to/paper_data.db
# Re-run outputs: "Imported 0 new, skipped X duplicates"
Files to Touch
backend/apps/surplus/models.py— addextractor_fingerprintfield to Productbackend/apps/surplus/management/commands/sync_extractor.py— new commandbackend/apps/surplus/migrations/0003_*.py— migration for new fieldbackend/common/services/extractor_sync.py— sync logic (reusable for webhook later)
B2B-061: Datasheet Upload Model + API
Priority: P0 Type: Backend Depends on: B2B-060
What
New DatasheetUpload model and API endpoints for mills (and admins) to upload PDF datasheets. Tracks upload → processing → review → accepted/rejected lifecycle.
Acceptance Criteria
- AC1: New model
DatasheetUploadwith fields:id(UUID),mill(FK),uploaded_by(FK User),file(FileField),original_filename,status(enum: pending/processing/extracted/review/accepted/rejected),extracted_data(JSONField),matched_products(JSONField),admin_notes(TextField),created_at,updated_at,processed_at,reviewed_by,reviewed_at - AC2:
POST /api/datasheets/upload/— accepts multipart PDF upload (max 10MB), creates DatasheetUpload with status=pending, returns upload ID - AC3:
GET /api/datasheets/— list all uploads. Admin sees all; mill_user sees own mill's only. Filterable by status, mill, date range. - AC4:
GET /api/datasheets/{id}/— detail view with extracted_data and matched_products - AC5:
PATCH /api/datasheets/{id}/review/— admin only. Body:{"action": "accept"|"reject"|"edit", "admin_notes": "...", "edited_data": {...}}. Updates status accordingly. - AC6: Mill user can only upload for their own mill. Admin can upload for any mill (mill_id in body).
- AC7: File stored in
MEDIA_ROOT/datasheets/{mill_id}/{filename}
API Contract
Upload:
POST /api/datasheets/upload/
Content-Type: multipart/form-data
Body: file=<PDF>, mill=<uuid> (optional, admin only)
Response 201: {
"id": "uuid",
"status": "pending",
"original_filename": "Navigator_Premium_Specs.pdf",
"created_at": "2026-03-13T16:00:00Z"
}
List:
GET /api/datasheets/?status=review&mill=<uuid>&page=1
Response 200: { "count": 42, "results": [...] }
Review:
PATCH /api/datasheets/{id}/review/
Body: {"action": "accept", "admin_notes": "Looks good"}
Response 200: {"id": "...", "status": "accepted", ...}
Playwright Test Expectations
test('admin can see datasheet upload list', async ({ page }) => {
// Login as admin
await page.goto('/manage/datasheets');
await expect(page.locator('h1')).toContainText('Datasheet');
// Table or empty state should be visible
await expect(page.locator('[data-testid="datasheet-list"], [data-testid="empty-state"]')).toBeVisible();
});
test('admin can filter datasheets by status', async ({ page }) => {
await page.goto('/manage/datasheets');
await page.locator('[data-testid="status-filter"]').click();
await page.locator('mat-option:has-text("Review")').click();
// URL should update with ?status=review
await expect(page).toHaveURL(/status=review/);
});
Files to Touch
backend/apps/datasheets/__init__.py— new appbackend/apps/datasheets/models.py— DatasheetUpload modelbackend/apps/datasheets/serializers.pybackend/apps/datasheets/views.py— DatasheetUploadViewSetbackend/apps/datasheets/urls.pybackend/apps/datasheets/admin.pybackend/config/settings/base.py— add to INSTALLED_APPS, MEDIA configbackend/config/urls.py— wire /api/datasheets/
B2B-062: AI Extraction Integration (Extractor Pipeline)
Priority: P0 Type: Backend Depends on: B2B-061
What
When a DatasheetUpload is created (status=pending), a Celery task sends the PDF to the Extractor pipeline (localhost:8925) for processing. When extraction completes, the extracted specs are stored back on the DatasheetUpload record.
Acceptance Criteria
- AC1: Celery task
process_datasheet_uploadtriggered on DatasheetUpload creation (post_save signal or explicit call) - AC2: Task sends PDF to Extractor via
POST http://localhost:8925/upload(multipart) - AC3: Task polls
GET http://localhost:8925/status/{job_id}until status=done (max 120s, poll every 3s) - AC4: On completion, task fetches results from Extractor and stores in
DatasheetUpload.extracted_dataas JSON - AC5:
extracted_datastructure:{"products": [{"name": "...", "paper_type": "...", "gsm": 120, ...}], "raw_text": "...", "extractor_job_id": "..."} - AC6: Status transitions: pending → processing → extracted (success) or pending → processing → error (failure)
- AC7: On error,
DatasheetUpload.extracted_datacontains{"error": "message", "extractor_job_id": "..."} - AC8: Retry up to 3 times on transient errors (connection refused, timeout)
API Contract
Internal Celery task. Extractor API:
POST http://localhost:8925/upload
Content-Type: multipart/form-data
Body: file=<PDF>
Response: {"job_id": "abc123", "status": "queued"}
GET http://localhost:8925/status/abc123
Response: {"job_id": "abc123", "status": "done", "products": [...]}
Playwright Test Expectations
test('uploaded datasheet shows processing status', async ({ page }) => {
// After upload, navigate to datasheet detail
await page.goto('/manage/datasheets/{id}');
// Should show processing indicator or extracted results
await expect(page.locator('[data-testid="extraction-status"]')).toBeVisible();
});
Files to Touch
backend/apps/datasheets/tasks.py— Celery taskbackend/apps/datasheets/services.py— ExtractorClient class (HTTP calls to localhost:8925)backend/apps/datasheets/signals.py— post_save triggerbackend/apps/datasheets/apps.py— register signals
B2B-063: Product Matching Engine
Priority: P0 Type: Backend Depends on: B2B-062, B2B-060
What
After extraction, each extracted product is matched against existing Products in the Marketplace DB. Uses a scoring algorithm: exact match (same mill + paper_type + GSM + width) → close match (same type + GSM, different mill/width) → no match (new product). Results stored in DatasheetUpload.matched_products.
Acceptance Criteria
- AC1: Service function
match_extracted_products(extracted_data, mill_id) → matched_products[] - AC2: Each matched product has:
{"extracted": {...}, "match_type": "exact|close|new", "confidence": 0.0-1.0, "matched_product_id": "uuid"|null, "matched_product_name": "..."|null} - AC3: Exact match (confidence ≥ 0.95): same mill + same paper_type + GSM within ±2 + width within ±10mm
- AC4: Close match (confidence 0.6-0.94): same paper_type + GSM within ±5, different mill or width differs
- AC5: No match (confidence < 0.6): new product candidate
- AC6: After matching, DatasheetUpload status transitions: extracted → review
- AC7:
matched_productsJSON stored on DatasheetUpload for admin review - AC8: Celery task
match_datasheet_productsruns automatically after extraction completes
API Contract
Internal service. Output stored on DatasheetUpload:
{
"matched_products": [
{
"extracted": {"name": "Kraftliner 120", "gsm": 120, "paper_type": "kraftliner", ...},
"match_type": "exact",
"confidence": 0.97,
"matched_product_id": "uuid-of-existing-product",
"matched_product_name": "Kraftliner Brown 120"
},
{
"extracted": {"name": "Special Anti-Grease 45", "gsm": 45, ...},
"match_type": "new",
"confidence": 0.15,
"matched_product_id": null,
"matched_product_name": null
}
]
}
Playwright Test Expectations
test('datasheet detail shows matched products with confidence', async ({ page }) => {
await page.goto('/manage/datasheets/{id}');
// Should show product match cards
await expect(page.locator('[data-testid="match-card"]').first()).toBeVisible();
// Each card shows confidence badge
await expect(page.locator('[data-testid="confidence-badge"]').first()).toBeVisible();
});
Files to Touch
backend/apps/datasheets/matching.py— matching algorithmbackend/apps/datasheets/tasks.py— add match_datasheet_products taskbackend/apps/datasheets/tests/test_matching.py— unit tests for matching logic
B2B-064: Admin Review Dashboard
Priority: P0 Type: Frontend Depends on: B2B-063
What
Admin-only screen showing all datasheet uploads as an inbox. For each datasheet: original PDF link, extracted products, AI classification, match results with confidence scores. Admin can accept (creates/links products), edit (modify before accepting), or reject.
Acceptance Criteria
- AC1: Route
/manage/datasheets— admin only, shows paginated list of all uploads - AC2: List columns: Upload Date, Mill Name, Filename (clickable → PDF), Status badge (pending/processing/extracted/review/accepted/rejected), Product Count, Reviewer
- AC3: Filterable by: status (multi-select), mill (dropdown), date range
- AC4: Click row →
/manage/datasheets/{id}detail view - AC5: Detail view shows: original PDF (embedded viewer or download link), extracted products in editable table
- AC6: Each extracted product row shows: name, paper_type, gsm, key specs, match result (exact/close/new), confidence %, linked product name (if matched)
- AC7: Per-product actions: ✅ Accept (creates Product or links to existing), ✏️ Edit (inline edit fields, then accept), ❌ Skip (ignore this product)
- AC8: Bulk action: "Accept All" button for high-confidence matches (≥ 0.90)
- AC9: "Reject Datasheet" button with required notes field
- AC10: After all products reviewed, datasheet status → accepted or rejected
- AC11: Sidebar nav: "Datasheets" link with badge showing pending review count
Playwright Test Expectations
test('admin sees datasheet inbox', async ({ page }) => {
await loginAsAdmin(page);
await page.goto('/manage/datasheets');
await expect(page.locator('h1')).toContainText('Datasheet');
await expect(page.locator('table, [data-testid="datasheet-list"]')).toBeVisible();
});
test('admin can open datasheet detail', async ({ page }) => {
await loginAsAdmin(page);
await page.goto('/manage/datasheets');
await page.locator('tr').nth(1).click(); // click first row
await expect(page.locator('[data-testid="extracted-products"]')).toBeVisible();
await expect(page.locator('[data-testid="match-card"]').first()).toBeVisible();
});
test('admin can accept a high-confidence match', async ({ page }) => {
await loginAsAdmin(page);
await page.goto('/manage/datasheets/{id}');
const card = page.locator('[data-testid="match-card"]').first();
await card.locator('button:has-text("Accept")').click();
await expect(card.locator('[data-testid="status-badge"]')).toContainText('Accepted');
});
test('admin can bulk accept all high-confidence matches', async ({ page }) => {
await loginAsAdmin(page);
await page.goto('/manage/datasheets/{id}');
await page.locator('button:has-text("Accept All")').click();
await expect(page.locator('[data-testid="accepted-count"]')).toBeVisible();
});
test('admin can reject a datasheet with notes', async ({ page }) => {
await loginAsAdmin(page);
await page.goto('/manage/datasheets/{id}');
await page.locator('button:has-text("Reject")').click();
await page.locator('[data-testid="reject-notes"]').fill('Wrong mill');
await page.locator('button:has-text("Confirm Reject")').click();
await expect(page.locator('[data-testid="status-badge"]')).toContainText('Rejected');
});
test('mill user cannot access datasheets admin', async ({ page }) => {
await loginAsMill(page);
await page.goto('/manage/datasheets');
// Should redirect or show 403
await expect(page).not.toHaveURL('/manage/datasheets');
});
Files to Touch
frontend/src/app/features/datasheets/datasheet-list.component.tsfrontend/src/app/features/datasheets/datasheet-detail.component.tsfrontend/src/app/features/datasheets/datasheet.service.tsfrontend/src/app/features/datasheets/datasheets.routes.tsfrontend/src/app/app.routes.ts— add /manage/datasheetsfrontend/src/app/shared/components/layout/sidebar.component.ts— add nav link + badge
B2B-065: Mill Datasheet Upload UI
Priority: P1 Type: Frontend Depends on: B2B-062
What
Mill-facing upload page. Mill user drags/drops a PDF datasheet, sees processing status, and gets confirmation when extraction is complete and sent for admin review.
Acceptance Criteria
- AC1: Route
/manage/datasheets/upload— accessible to mill_user and admin - AC2: Drag-and-drop zone accepts PDF files only (max 10MB)
- AC3: On upload: shows progress bar → "Processing..." with spinner → "Extraction complete — sent for review" ✅
- AC4: After upload, shows summary: "We found X products in your datasheet. An admin will review and add them to your catalog."
- AC5: Mill user can see their upload history at
/manage/datasheets(filtered to own mill) - AC6: Each upload shows status: Processing / Under Review / Accepted / Rejected
- AC7: Admin selector: when admin uploads, shows mill dropdown to assign the datasheet
Playwright Test Expectations
test('mill user can upload a PDF datasheet', async ({ page }) => {
await loginAsMill(page);
await page.goto('/manage/datasheets/upload');
await expect(page.locator('[data-testid="upload-zone"]')).toBeVisible();
await expect(page.locator('text=PDF')).toBeVisible();
});
test('upload zone rejects non-PDF files', async ({ page }) => {
await loginAsMill(page);
await page.goto('/manage/datasheets/upload');
// Try uploading a .txt file
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({ name: 'test.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') });
await expect(page.locator('[data-testid="error-message"]')).toContainText('PDF');
});
test('mill user sees their upload history', async ({ page }) => {
await loginAsMill(page);
await page.goto('/manage/datasheets');
// Should only see own mill's datasheets
await expect(page.locator('table, [data-testid="datasheet-list"]')).toBeVisible();
});
Files to Touch
frontend/src/app/features/datasheets/datasheet-upload.component.tsfrontend/src/app/features/datasheets/datasheets.routes.ts— add upload route
B2B-066: Clone Product
Priority: P1 Type: Full-stack Depends on: B2B-051 only
What
"Clone" button on product detail/list that duplicates a product with all its specs, opens the edit form pre-filled. Mill user changes GSM (or other fields) and saves as new product. Killer UX for mills that produce the same paper in 6 different weights.
Acceptance Criteria
- AC1: "Clone" button visible on product detail page and product list (action column)
- AC2:
POST /api/products/{id}/clone/— creates a copy of the product with name suffixed " (Copy)", all specs duplicated,is_active=false(draft) - AC3: Response returns the new product → frontend navigates to
/manage/products/{new_id}/edit - AC4: Form is fully pre-filled with cloned data. User edits what they want (typically just GSM + name) and saves.
- AC5: Cloned product is independent — editing the clone does NOT affect the original
- AC6: Mill user can only clone their own products. Admin can clone any.
API Contract
POST /api/products/{id}/clone/
Response 201: { "id": "new-uuid", "name": "Kraftliner 120 (Copy)", ... }
Playwright Test Expectations
test('clone button creates a copy and opens edit form', async ({ page }) => {
await loginAsMill(page);
await page.goto('/manage/products/{id}');
await page.locator('button:has-text("Clone")').click();
// Should navigate to edit form of new product
await expect(page).toHaveURL(/\/manage\/products\/.*\/edit/);
// Name should contain "(Copy)"
const nameInput = page.locator('input[formControlName="name"]');
await expect(nameInput).toHaveValue(/\(Copy\)/);
});
test('cloned product is independent from original', async ({ page }) => {
// Clone, change GSM, save
await loginAsMill(page);
await page.goto('/manage/products/{id}');
await page.locator('button:has-text("Clone")').click();
await page.locator('input[formControlName="gsm"]').fill('150');
await page.locator('input[formControlName="name"]').fill('Kraftliner 150');
await page.locator('button[type="submit"]').click();
await expect(page.locator('.notification')).toContainText('created');
});
Files to Touch
backend/apps/surplus/views.py— addcloneaction to ProductViewSetfrontend/src/app/features/products/product-detail.component.ts— add Clone buttonfrontend/src/app/features/products/product-list.component.ts— add Clone actionfrontend/src/app/features/products/product.service.ts— add clone() method
B2B-067: Excel Template Import
Priority: P1 Type: Full-stack Depends on: B2B-061
What
Downloadable .xlsx template with sample paper product data + dropdown validations. Upload endpoint parses the Excel, validates rows, shows preview with errors highlighted, and imports valid products on confirm.
Acceptance Criteria
- AC1:
GET /api/products/import-template/— returns downloadable .xlsx file - AC2: Template has Sheet 1 (product table with 4 sample rows + column headers with comments) and Sheet 2 (reference lists: valid paper_types, categories, forms, fiber_types)
- AC3: Sample rows are realistic paper products (kraftliner, testliner, CWF bond, coated paper)
- AC4: Template has Excel data validation dropdowns for enum fields (paper_type, form, fiber_type, category)
- AC5: Yellow instruction row at top: "Delete example rows and replace with your products. Required fields marked with *"
- AC6:
POST /api/products/import-preview/— accepts .xlsx upload, returns JSON preview:{"valid": [...], "errors": [{"row": 3, "field": "gsm", "error": "Required"}], "total": 12, "valid_count": 10, "error_count": 2} - AC7:
POST /api/products/import-confirm/— accepts the preview token/data, creates all valid products - AC8: Frontend: download template button → upload zone → preview table (green=valid, red=errors) → "Import X products" button
Playwright Test Expectations
test('download template button returns xlsx file', async ({ page }) => {
await loginAsMill(page);
await page.goto('/manage/products');
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('button:has-text("Template")').click(),
]);
expect(download.suggestedFilename()).toMatch(/\.xlsx$/);
});
test('excel upload shows preview with validation', async ({ page }) => {
await loginAsMill(page);
await page.goto('/manage/products/import');
// Upload a valid xlsx
await page.locator('input[type="file"]').setInputFiles('test-fixtures/sample-products.xlsx');
await expect(page.locator('[data-testid="preview-table"]')).toBeVisible();
await expect(page.locator('[data-testid="valid-count"]')).toContainText(/\d+/);
});
test('confirm import creates products', async ({ page }) => {
await loginAsMill(page);
await page.goto('/manage/products/import');
await page.locator('input[type="file"]').setInputFiles('test-fixtures/sample-products.xlsx');
await page.locator('button:has-text("Import")').click();
await expect(page.locator('.notification')).toContainText('imported');
});
Files to Touch
backend/apps/surplus/views.py— import_template, import_preview, import_confirm actionsbackend/apps/surplus/services/excel_import.py— template generation + parsing logicbackend/requirements.txt— addopenpyxlif not presentfrontend/src/app/features/products/product-import.component.ts— new componentfrontend/src/app/features/products/products.routes.ts— add /import routefrontend/src/app/features/products/product-list.component.ts— add Import + Template buttons
B2B-068: Empty State + Onboarding Flow
Priority: P2 Type: Frontend Depends on: B2B-065, B2B-067
What
When a mill user logs in and has zero products, show a guided onboarding screen instead of an empty table. Three clear paths: upload PDF datasheet, import from Excel, or add manually.
Acceptance Criteria
- AC1: Mill dashboard shows "Your product catalog is empty" card when product count = 0
- AC2: Card has 3 CTAs: "Upload Datasheet (PDF)" → /manage/datasheets/upload, "Import from Excel" → /manage/products/import, "Add Manually" → /manage/products/new
- AC3: Products list page also shows empty state with same 3 CTAs when no products exist
- AC4: Empty state disappears once mill has ≥ 1 product
- AC5: Admin dashboard shows aggregate: "X mills with 0 products" as action item
- AC6: Brief explanation text: "Add your products so buyers can find your surplus. Most mills upload their existing datasheet — it takes 2 minutes."
Playwright Test Expectations
test('new mill user sees onboarding empty state', async ({ page }) => {
await loginAsNewMill(page); // mill with 0 products
await page.goto('/manage/products');
await expect(page.locator('[data-testid="empty-state"]')).toBeVisible();
await expect(page.locator('text=Upload Datasheet')).toBeVisible();
await expect(page.locator('text=Import from Excel')).toBeVisible();
await expect(page.locator('text=Add Manually')).toBeVisible();
});
test('empty state disappears when products exist', async ({ page }) => {
await loginAsMill(page); // mill WITH products
await page.goto('/manage/products');
await expect(page.locator('[data-testid="empty-state"]')).not.toBeVisible();
await expect(page.locator('table')).toBeVisible();
});
test('dashboard shows catalog empty warning for new mill', async ({ page }) => {
await loginAsNewMill(page);
await page.goto('/manage');
await expect(page.locator('[data-testid="catalog-empty-card"]')).toBeVisible();
});
Files to Touch
frontend/src/app/features/products/product-list.component.ts— add empty statefrontend/src/app/features/admin/admin.component.ts— add "mills with 0 products" statfrontend/src/app/shared/components/empty-state.component.ts— reusable empty state component (optional)
Summary: Build Order
| Sprint | Tickets | What | Estimated |
|---|---|---|---|
| Sprint 1 | B2B-060, B2B-066 | Sync bridge + Clone product | 1 day |
| Sprint 2 | B2B-061, B2B-062 | Upload model + AI extraction | 2 days |
| Sprint 3 | B2B-063, B2B-064, B2B-065 | Matching + Admin dashboard + Mill upload UI | 3 days |
| Sprint 4 | B2B-067, B2B-068 | Excel import + Onboarding flow | 2 days |
Total: ~8 days of dev work (can parallel backend/frontend in sprints 2-4).
Tech Notes
Extractor Pipeline Reference
- Location:
/home/claude/projects/paper-pdf-extractor/ - Port: 8925 (gunicorn)
- DB: SQLite at
paper_data.db - Model: Gemini 2.0 Flash via OpenRouter (
google/gemini-2.0-flash-001) - Upload endpoint:
POST /upload(multipart, returns job_id) - Status endpoint:
GET /status/{job_id} - Stats: 8,919 docs, 16,791 extracted products, 4,246 catalog products, 440 mills
Field Mapping: Extractor → Marketplace
| Extractor (catalog_products) | Marketplace (Product) |
|---|---|
| name | name |
| category | paper_type (needs mapping) |
| gsm | gsm |
| coating | coating |
| color | color |
| width_mm | width_mm |
| height_mm | length_mm |
| presentation | form |
| certifications (comma-sep) | certifications (JSON array) |
| fiber_source | fiber_type |
| quality_grade | quality |
| product_code | product_code |
| brand | brand |
| fingerprint | extractor_fingerprint (new) |
| mill_id + mill_name | mill (FK) |
Category Mapping: Extractor → Marketplace paper_type
| Extractor category | Marketplace paper_type |
|---|---|
| Kraftliner | kraftliner |
| Testliner | testliner |
| Fluting Medium | fluting |
| Coated Paper C2S | coated |
| Uncoated Woodfree | writing |
| Newsprint | newsprint |
| Folding Boxboard (FBB) | board |
| Solid Bleached Board (SBS) | board |
| Kraft Paper | kraft |
| Thermal Paper | thermal |
| NCR / Carbonless Paper | ncr |
| Greaseproof Paper | greaseproof |
| (others) | other |