chore: initial commit of existing codebase
This commit is contained in:
commit
11e94889b2
292 files changed
+38586
No files matched your search
@@ -0,0 +1,214 @@
|
||||
# Purchase Pipeline — Design Spec
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved for implementation
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the disconnected purchase screens with a single end-to-end pipeline that tracks every purchase like a package delivery — each record shows exactly which stage it is at, who acted last, and what is needed next.
|
||||
|
||||
The pipeline has 8 stages. 3 new database tables are added. All existing models (PurchaseRequest, PurchaseOrder, GRN, SupplierPayment) are reused with minor additions.
|
||||
|
||||
---
|
||||
|
||||
## The 8 Stages
|
||||
|
||||
| # | Stage | Actor | Model |
|
||||
|---|-------|-------|-------|
|
||||
| 1 | Purchase Request created | Operations team | `purchase_requests` (existing) |
|
||||
| 2 | GM digital signature | GM | `purchase_signatures` (NEW) |
|
||||
| 3 | Suppliers selected + RFQ links sent | Purchasing team | `rfq_invitations` (NEW) |
|
||||
| 4 | Suppliers submit quotes via private link | Supplier (public portal) | `supplier_quotes` + `supplier_quote_items` (NEW) |
|
||||
| 5 | Quote comparison + winner selected | Purchasing team | `supplier_quotes` (awarded flag) |
|
||||
| 6 | LPO issued to winning supplier | Purchasing team | `purchase_orders` (existing, relabelled LPO) |
|
||||
| 7 | Materials received at site | Warehouse / Store Manager | `goods_receipt_notes` + `grn_items` (existing, + type field) |
|
||||
| 8 | Payment / cheque issued | Accounts | `supplier_payments` (existing) |
|
||||
|
||||
The active stage is stored as a `stage` enum column on `purchase_requests`. It advances automatically when each step is completed.
|
||||
|
||||
---
|
||||
|
||||
## New Database Tables
|
||||
|
||||
### `purchase_signatures`
|
||||
```
|
||||
id
|
||||
purchase_request_id FK → purchase_requests
|
||||
signed_by FK → users
|
||||
signature_image TEXT (base64 PNG from canvas)
|
||||
signed_at TIMESTAMP
|
||||
ip_address STRING nullable
|
||||
```
|
||||
|
||||
### `rfq_invitations`
|
||||
```
|
||||
id
|
||||
purchase_request_id FK → purchase_requests
|
||||
supplier_id FK → suppliers
|
||||
token STRING(64) UNIQUE — cryptographically random hex
|
||||
channel ENUM: email | whatsapp | both
|
||||
sent_at TIMESTAMP nullable
|
||||
opened_at TIMESTAMP nullable
|
||||
expires_at TIMESTAMP (sent_at + 7 days)
|
||||
status ENUM: pending | sent | opened | submitted | declined
|
||||
timestamps
|
||||
```
|
||||
|
||||
### `supplier_quotes`
|
||||
```
|
||||
id
|
||||
rfq_invitation_id FK → rfq_invitations
|
||||
purchase_request_id FK → purchase_requests (denormalised for easy querying)
|
||||
supplier_id FK → suppliers
|
||||
submitted_at TIMESTAMP
|
||||
lead_time_days INTEGER nullable
|
||||
payment_terms STRING nullable
|
||||
notes TEXT nullable
|
||||
total_amount DECIMAL(12,3)
|
||||
is_awarded BOOLEAN default false
|
||||
award_reason TEXT nullable
|
||||
awarded_at TIMESTAMP nullable
|
||||
awarded_by FK → users nullable
|
||||
timestamps
|
||||
```
|
||||
|
||||
### `supplier_quote_items`
|
||||
```
|
||||
id
|
||||
supplier_quote_id FK → supplier_quotes
|
||||
description TEXT
|
||||
unit STRING(50)
|
||||
quantity DECIMAL(10,3)
|
||||
unit_price DECIMAL(12,3)
|
||||
total_price DECIMAL(12,3)
|
||||
timestamps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Existing Table Changes
|
||||
|
||||
### `purchase_requests` — add column
|
||||
```
|
||||
stage ENUM: draft | gm_approval | rfq | quoting | comparison | lpo | receiving | payment | complete
|
||||
DEFAULT: draft
|
||||
```
|
||||
|
||||
### `grn_items` — add column
|
||||
```
|
||||
type ENUM: inventory | consumable DEFAULT: inventory
|
||||
```
|
||||
|
||||
No other existing tables are changed.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Internal routes (auth-protected)
|
||||
All existing purchase routes remain. New routes added:
|
||||
|
||||
```
|
||||
GET purchase/pipeline purchase.pipeline.index
|
||||
GET purchase/requests/{id}/sign purchase.requests.sign (GM signature page)
|
||||
POST purchase/requests/{id}/sign purchase.requests.sign.store
|
||||
GET purchase/requests/{id}/rfq purchase.requests.rfq (select suppliers)
|
||||
POST purchase/requests/{id}/rfq purchase.requests.rfq.store (send invitations)
|
||||
GET purchase/requests/{id}/quotes purchase.requests.quotes (view all quotes)
|
||||
POST purchase/requests/{id}/quotes/{quote}/award purchase.requests.quotes.award
|
||||
GET purchase/requests/{id}/compare purchase.requests.compare (comparison table)
|
||||
```
|
||||
|
||||
### Public routes (no auth)
|
||||
```
|
||||
GET /rfq/{token} RfqPortalController@show (supplier quote form)
|
||||
POST /rfq/{token} RfqPortalController@submit (supplier submits quote)
|
||||
```
|
||||
|
||||
Token is 64 hex chars generated via `bin2hex(random_bytes(32))`. Validated on every request — expired, already-submitted, or unknown tokens show a clear error page.
|
||||
|
||||
---
|
||||
|
||||
## UI Components
|
||||
|
||||
### 1. Pipeline index page (`purchase/pipeline`)
|
||||
Replaces the current fragmented purchase list. Shows all active purchases as cards, each with:
|
||||
- Vertical timeline showing completed stages (checked, with date + actor)
|
||||
- Current stage highlighted in amber with action button
|
||||
- Upcoming stages grayed out
|
||||
|
||||
### 2. GM Signature modal
|
||||
- HTML5 Canvas — GM draws with mouse or touch
|
||||
- "Clear" and "Confirm Signature" buttons
|
||||
- On confirm: canvas serialised to base64 PNG, POSTed to server, stored in `purchase_signatures`
|
||||
- Signature image displayed on the request detail forever after
|
||||
|
||||
### 3. RFQ Invitation screen
|
||||
- Checklist of suppliers from the suppliers table (search/filter)
|
||||
- Per-supplier: toggle WhatsApp / Email / Both
|
||||
- "Send Invitations" generates tokens, logs `rfq_invitations`, sends messages
|
||||
- WhatsApp: deep link `https://wa.me/{number}?text=...` with the URL embedded
|
||||
- Email: Laravel `Mail` with the unique URL
|
||||
|
||||
### 4. Public supplier portal (`/rfq/{token}`)
|
||||
- No login, no navigation — clean standalone page
|
||||
- Shows: your company name, request reference, list of items needed (description + quantity)
|
||||
- Supplier fills: unit price per item, lead time (days), payment terms, notes
|
||||
- One submit only — redirects to a thank-you screen
|
||||
- After submission: `rfq_invitations.status` → submitted, `supplier_quotes` record created
|
||||
|
||||
### 5. Quote comparison table
|
||||
- Side-by-side: one column per supplier who submitted
|
||||
- Rows: each item, plus totals row, lead time, payment terms
|
||||
- Lowest price per item highlighted in green
|
||||
- "Award to this supplier" button per column — opens a modal to enter reason
|
||||
- Awarding locks the comparison and advances stage to `lpo`
|
||||
|
||||
### 6. Vertical timeline (on every purchase detail)
|
||||
Each completed stage shows:
|
||||
- Stage name + icon
|
||||
- Actor name (who did it)
|
||||
- Timestamp
|
||||
- Any key data (e.g. "Signed by Ahmed Al-Rashid", "3 quotes received", "LPO sent to Safety Chemical Trading")
|
||||
|
||||
Current stage pulses amber. Future stages are gray.
|
||||
|
||||
---
|
||||
|
||||
## New Controllers / Services
|
||||
|
||||
| File | Responsibility |
|
||||
|------|---------------|
|
||||
| `PurchasePipelineController` | Pipeline index — lists all purchases by stage |
|
||||
| `PurchaseSignatureController` | Store GM signature, advance stage |
|
||||
| `RfqController` | Select suppliers, generate tokens, send invites |
|
||||
| `SupplierQuoteController` | View quotes, award winner |
|
||||
| `RfqPortalController` | Public: show form, accept submission |
|
||||
| `RfqInvitationService` | Generate token, send WhatsApp link + email |
|
||||
| `PurchaseStageService` | Single place that advances `stage` and logs the event |
|
||||
|
||||
---
|
||||
|
||||
## New Models
|
||||
|
||||
`PurchaseSignature`, `RfqInvitation`, `SupplierQuote`, `SupplierQuoteItem`
|
||||
|
||||
---
|
||||
|
||||
## Notification Channels
|
||||
|
||||
**WhatsApp:** Generate a `wa.me` deep-link with the RFQ URL pre-filled in the message body. User clicks the link in the system → WhatsApp opens on their device → they send it to the supplier. (No WhatsApp API key needed.)
|
||||
|
||||
**Email:** Standard Laravel `Mail::to($supplier->email)->send(new RfqInvitationMail($invitation))`.
|
||||
|
||||
---
|
||||
|
||||
## Constraints & Rules
|
||||
|
||||
- A purchase cannot advance past stage 2 (GM sign) without a signature record
|
||||
- A purchase cannot advance past stage 4 (quoting) with fewer than 1 submitted quote (minimum 1, recommended 3)
|
||||
- A token expires 7 days after `sent_at`; expired tokens show an expiry message, not an error
|
||||
- A quote token can only be submitted once; re-visiting after submit shows the thank-you screen
|
||||
- Awarding a quote is irreversible — all other quotes are marked `declined`
|
||||
- GRN items default to `inventory`; the receiving form lets the user toggle each item to `consumable`
|
||||
@@ -0,0 +1,271 @@
|
||||
# Ultra Message — Laravel WhatsApp Package Design
|
||||
|
||||
**Date:** 2026-05-19
|
||||
**Project:** OperationModule (SteelERP) + reusable across future Laravel projects
|
||||
**Package name:** `promoseven/ultra-message`
|
||||
**API provider:** UltraMSG (https://docs.ultramsg.com/)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A reusable Laravel package that wraps the UltraMSG WhatsApp API. It ships as a standalone Composer package hosted on a private GitHub repo and is installed via VCS in any Laravel project. It integrates with Laravel's Notification system, supports queuing, exposes a Facade for one-liner sends, and handles incoming webhooks by firing a generic Laravel event. OperationModule adds a database-backed Settings UI so admins can configure credentials without touching `.env`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Package Repository
|
||||
|
||||
- **Repo name:** `ultra-message` (GitHub, under Promoseven org)
|
||||
- **Composer name:** `promoseven/ultra-message`
|
||||
- **Required in projects via** `composer.json` VCS entry pointing to the GitHub repo
|
||||
- **Auto-discovered** via Laravel's package discovery (`extra.laravel.providers`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Package File Structure
|
||||
|
||||
```
|
||||
ultra-message/
|
||||
├── src/
|
||||
│ ├── UltraMessageServiceProvider.php
|
||||
│ ├── UltraMessageClient.php
|
||||
│ ├── UltraMessageChannel.php
|
||||
│ ├── UltraMessageMessage.php
|
||||
│ ├── UltraMessageException.php
|
||||
│ ├── Facades/
|
||||
│ │ └── UltraMessage.php
|
||||
│ └── Events/
|
||||
│ └── UltraMessageWebhookReceived.php
|
||||
├── config/
|
||||
│ └── ultra-message.php
|
||||
├── routes/
|
||||
│ └── webhook.php
|
||||
├── tests/
|
||||
│ ├── UltraMessageClientTest.php
|
||||
│ └── UltraMessageChannelTest.php
|
||||
├── composer.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration (`config/ultra-message.php`)
|
||||
|
||||
```php
|
||||
return [
|
||||
'instance_id' => env('ULTRAMSG_INSTANCE_ID'),
|
||||
'token' => env('ULTRAMSG_TOKEN'),
|
||||
'webhook_secret' => env('ULTRAMSG_WEBHOOK_SECRET', null),
|
||||
'webhook_path' => env('ULTRAMSG_WEBHOOK_PATH', 'ultra-message/webhook'),
|
||||
'timeout' => env('ULTRAMSG_TIMEOUT', 30),
|
||||
'enabled' => env('ULTRAMSG_ENABLED', true),
|
||||
];
|
||||
```
|
||||
|
||||
**Dynamic config override:** The service provider exposes `UltraMessage::configUsing(callable $resolver)`. When set, the resolver is called at runtime to return an array of config values — used by OperationModule to read credentials from the database instead of `.env`.
|
||||
|
||||
```php
|
||||
// In OperationModule AppServiceProvider::boot()
|
||||
UltraMessage::configUsing(fn() => [
|
||||
'instance_id' => Setting::get('ultramsg_instance_id'),
|
||||
'token' => Setting::get('ultramsg_token'),
|
||||
'enabled' => Setting::get('ultramsg_enabled', true),
|
||||
]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. The Client (`UltraMessageClient`)
|
||||
|
||||
Wraps all UltraMSG HTTP calls via Laravel's `Http` facade. Base URL: `https://api.ultramsg.com/{instance_id}/messages/`.
|
||||
|
||||
### Outbound methods
|
||||
|
||||
```php
|
||||
sendText(string $to, string $message, ?string $replyId = null): array
|
||||
sendImage(string $to, string $imageUrl, string $caption = ''): array
|
||||
sendDocument(string $to, string $fileUrl, string $filename, string $caption = ''): array
|
||||
sendAudio(string $to, string $audioUrl): array
|
||||
sendVoice(string $to, string $audioUrl): array
|
||||
sendVideo(string $to, string $videoUrl, string $caption = ''): array
|
||||
sendSticker(string $to, string $stickerUrl): array
|
||||
sendContact(string $to, string $contactId): array
|
||||
sendLocation(string $to, float $lat, float $lng, string $address = ''): array
|
||||
sendReaction(string $to, string $messageId, string $emoji): array
|
||||
deleteMessage(string $messageId): array
|
||||
```
|
||||
|
||||
### Instance / account info
|
||||
|
||||
```php
|
||||
getInstanceStatus(): array
|
||||
getChats(): array
|
||||
getContacts(): array
|
||||
getGroups(): array
|
||||
```
|
||||
|
||||
### Error handling
|
||||
|
||||
All methods throw `UltraMessageException` on HTTP failure (non-2xx) or when the UltraMSG response contains an error field. Callers catch one exception type.
|
||||
|
||||
If `enabled` is `false` in config, all send methods return early silently (no exception, no HTTP call).
|
||||
|
||||
---
|
||||
|
||||
## 5. Message DTO (`UltraMessageMessage`)
|
||||
|
||||
A fluent DTO used inside Laravel Notifications:
|
||||
|
||||
```php
|
||||
UltraMessageMessage::text('Order confirmed.')
|
||||
UltraMessageMessage::image($url, 'Caption')
|
||||
UltraMessageMessage::document($url, 'invoice.pdf', 'Your invoice')
|
||||
UltraMessageMessage::audio($url)
|
||||
UltraMessageMessage::video($url, 'Caption')
|
||||
UltraMessageMessage::location($lat, $lng, 'Address')
|
||||
UltraMessageMessage::contact($contactId)
|
||||
```
|
||||
|
||||
Each static constructor sets the `type` and relevant properties. The `->to(string $number)` method sets the recipient (overrides the notifiable's route).
|
||||
|
||||
---
|
||||
|
||||
## 6. Notification Channel (`UltraMessageChannel`)
|
||||
|
||||
Implements `Illuminate\Notifications\Channels\Channel`. When a Notification defines `toUltraMessage()`, this channel:
|
||||
|
||||
1. Resolves the recipient from `$notifiable->routeNotificationFor('ultra_message')` or from `$message->to`
|
||||
2. Calls the correct `$client->send*()` method based on `$message->type`
|
||||
3. Supports `ShouldQueue` — the notification queues normally via Laravel's queue system
|
||||
|
||||
### Usage in OperationModule
|
||||
|
||||
```php
|
||||
class PurchaseOrderConfirmed extends Notification implements ShouldQueue
|
||||
{
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return [UltraMessageChannel::class];
|
||||
}
|
||||
|
||||
public function toUltraMessage($notifiable): UltraMessageMessage
|
||||
{
|
||||
return UltraMessageMessage::text("PO #{$this->order->number} confirmed.")
|
||||
->to($notifiable->whatsapp_number);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Facade (`UltraMessage`)
|
||||
|
||||
Maps to `UltraMessageClient` for one-liner sends outside of the Notification system:
|
||||
|
||||
```php
|
||||
use PromoSeven\UltraMessage\Facades\UltraMessage;
|
||||
|
||||
UltraMessage::sendText('+971501234567', 'Your invoice is ready.');
|
||||
UltraMessage::sendDocument('+971501234567', $pdfUrl, 'invoice.pdf', 'Invoice #123');
|
||||
```
|
||||
|
||||
Also exposes:
|
||||
```php
|
||||
UltraMessage::configUsing(callable $resolver); // dynamic config
|
||||
UltraMessage::fake(); // test mode
|
||||
UltraMessage::assertSent(callable $callback); // test assertion
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Webhook Handling
|
||||
|
||||
The service provider registers `POST /{webhook_path}` (default: `ultra-message/webhook`) outside the `auth` middleware group, excluded from CSRF via `VerifyCsrfToken`.
|
||||
|
||||
On each request:
|
||||
1. If `webhook_secret` is set — verify HMAC-SHA256 signature from the `X-Hub-Signature-256` header; return `403` on mismatch
|
||||
2. Fire `UltraMessageWebhookReceived` event with the full raw payload array
|
||||
3. Return `200 OK`
|
||||
|
||||
**Consuming in OperationModule:**
|
||||
```php
|
||||
// In EventServiceProvider
|
||||
protected $listen = [
|
||||
UltraMessageWebhookReceived::class => [
|
||||
HandleIncomingWhatsApp::class,
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing Support
|
||||
|
||||
```php
|
||||
UltraMessage::fake();
|
||||
// ... trigger code that sends ...
|
||||
UltraMessage::assertSent(fn($msg) => $msg->to === '+971501234567' && $msg->type === 'text');
|
||||
UltraMessage::assertNotSent();
|
||||
UltraMessage::assertSentCount(3);
|
||||
```
|
||||
|
||||
In fake mode, no HTTP calls are made. All sends are recorded in memory for assertion.
|
||||
|
||||
---
|
||||
|
||||
## 10. Settings UI in OperationModule
|
||||
|
||||
### Database
|
||||
|
||||
A new `settings` table: `id, key (unique), value, created_at, updated_at`.
|
||||
A `Setting` model with static helpers: `Setting::get($key, $default)` and `Setting::set($key, $value)`.
|
||||
|
||||
### Route & Controller
|
||||
|
||||
```
|
||||
GET /settings/integrations → SettingsController@integrations
|
||||
POST /settings/integrations/whatsapp → SettingsController@updateWhatsapp
|
||||
```
|
||||
|
||||
Protected by `auth`, `verified`, and `role:Admin`.
|
||||
|
||||
### View
|
||||
|
||||
A new **Settings** entry in the sidebar (Admin only) leading to an Integrations page with a WhatsApp section:
|
||||
|
||||
| Field | Input type |
|
||||
|---|---|
|
||||
| Enable WhatsApp | Toggle switch |
|
||||
| Instance ID | Text input |
|
||||
| API Token | Password input (masked, show/hide toggle) |
|
||||
| Webhook Secret | Password input (masked, show/hide toggle) |
|
||||
| Webhook URL | Read-only display (auto-generated from `webhook_path`) |
|
||||
| Connection status | Badge — shows live status via `getInstanceStatus()` |
|
||||
|
||||
On save → toast success/error. "Test Connection" button calls `getInstanceStatus()` and shows result inline.
|
||||
|
||||
---
|
||||
|
||||
## 11. Integration Points in OperationModule
|
||||
|
||||
Once the package and settings UI are in place, notifications can be wired to these events:
|
||||
|
||||
| Trigger | Message |
|
||||
|---|---|
|
||||
| PO confirmed | Supplier: "Your PO #{number} has been confirmed." |
|
||||
| GRN confirmed | Store: "GRN #{number} received and confirmed." |
|
||||
| Sales order confirmed | Customer: "Your order #{number} is confirmed." |
|
||||
| Invoice created | Customer: "Invoice #{number} is ready. Amount: {total}" |
|
||||
| Delivery dispatched | Customer: "Your delivery is on the way." |
|
||||
| Low stock alert | Store Manager: "Item {name} is below reorder level." |
|
||||
| Production order completed | Production Manager: "PO #{number} completed." |
|
||||
|
||||
Each notification is a separate `Notification` class using `UltraMessageChannel`. This wiring is done in OperationModule, not in the package.
|
||||
|
||||
---
|
||||
|
||||
## 12. Implementation Phases
|
||||
|
||||
1. **Phase 1 — Package** (`ultra-message` repo): ServiceProvider, Client, Channel, Message DTO, Facade, Webhook handler, Fake/test support
|
||||
2. **Phase 2 — OperationModule integration**: `settings` table + `Setting` model, Settings UI (sidebar + form), dynamic config boot, CSRF exclusion for webhook route
|
||||
3. **Phase 3 — Notifications**: Wire individual notification classes for each ERP event listed above
|
||||
Reference in new issue
Block a user