chore: initial commit of existing codebase
This commit is contained in:
commit
11e94889b2
375
.claude/mcp-server.py
Normal file
375
.claude/mcp-server.py
Normal file
@ -0,0 +1,375 @@
|
||||
"""
|
||||
OperationModule Database MCP Server
|
||||
Gives Claude direct read-only access to the SQLite database so queries
|
||||
don't require reading PHP files or running artisan commands.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# ── MCP SDK ──────────────────────────────────────────────────────────────────
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
DB_PATH = Path(__file__).parent.parent / "database" / "database.sqlite"
|
||||
|
||||
mcp = FastMCP("OperationModule DB")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _rows(sql: str, params: tuple = ()) -> list[dict]:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute(sql, params)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _one(sql: str, params: tuple = ()) -> dict | None:
|
||||
rows = _rows(sql, params)
|
||||
return rows[0] if rows else None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Schema & Meta
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
def list_tables() -> str:
|
||||
"""List all tables in the database with row counts."""
|
||||
rows = _rows("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
result = []
|
||||
with _conn() as conn:
|
||||
for r in rows:
|
||||
t = r["name"]
|
||||
count = conn.execute(f"SELECT COUNT(*) FROM [{t}]").fetchone()[0]
|
||||
result.append(f"{t}: {count} rows")
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def describe_table(table: str) -> str:
|
||||
"""Return the column definitions for a table."""
|
||||
rows = _rows(f"PRAGMA table_info([{table}])")
|
||||
if not rows:
|
||||
return f"Table '{table}' not found."
|
||||
lines = [f"Table: {table}"]
|
||||
for c in rows:
|
||||
nullable = "" if c["notnull"] else " NULL"
|
||||
default = f" DEFAULT {c['dflt_value']}" if c["dflt_value"] is not None else ""
|
||||
pk = " PK" if c["pk"] else ""
|
||||
lines.append(f" {c['name']} {c['type']}{nullable}{default}{pk}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def run_query(sql: str) -> str:
|
||||
"""
|
||||
Execute a read-only SQL query and return results as JSON.
|
||||
Only SELECT statements are allowed. Max 200 rows returned.
|
||||
"""
|
||||
stripped = sql.strip().upper()
|
||||
if not stripped.startswith("SELECT") and not stripped.startswith("WITH"):
|
||||
return "Error: only SELECT / WITH queries are permitted."
|
||||
try:
|
||||
rows = _rows(sql)
|
||||
if len(rows) > 200:
|
||||
rows = rows[:200]
|
||||
suffix = "\n[Truncated to 200 rows]"
|
||||
else:
|
||||
suffix = ""
|
||||
return json.dumps(rows, default=str, indent=2) + suffix
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Purchase Module
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
def get_suppliers(active_only: bool = False) -> str:
|
||||
"""List all suppliers with their contact details."""
|
||||
where = "WHERE is_active = 1" if active_only else ""
|
||||
rows = _rows(f"""
|
||||
SELECT id, name, contact_person, email, phone, address, tax_number,
|
||||
CASE WHEN is_active THEN 'Active' ELSE 'Inactive' END AS status
|
||||
FROM suppliers {where}
|
||||
ORDER BY name
|
||||
""")
|
||||
return json.dumps(rows, default=str, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_purchase_orders(status: str = "") -> str:
|
||||
"""
|
||||
List purchase orders. Optional status filter: draft|sent|partial|received|cancelled
|
||||
"""
|
||||
where = "WHERE po.status = ?" if status else ""
|
||||
params = (status,) if status else ()
|
||||
rows = _rows(f"""
|
||||
SELECT po.id, po.po_number, s.name AS supplier, po.po_date,
|
||||
po.expected_delivery_date, po.total_amount, po.status
|
||||
FROM purchase_orders po
|
||||
JOIN suppliers s ON s.id = po.supplier_id
|
||||
{where}
|
||||
ORDER BY po.po_date DESC
|
||||
LIMIT 100
|
||||
""", params)
|
||||
return json.dumps(rows, default=str, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_purchase_requests(status: str = "") -> str:
|
||||
"""
|
||||
List purchase requests. Optional status: pending|approved|rejected|ordered
|
||||
"""
|
||||
where = "WHERE pr.status = ?" if status else ""
|
||||
params = (status,) if status else ()
|
||||
rows = _rows(f"""
|
||||
SELECT pr.id, pr.request_number, pr.date, pr.department,
|
||||
pr.requested_by_name, pr.required_date_text, pr.status,
|
||||
COUNT(pri.id) AS item_count
|
||||
FROM purchase_requests pr
|
||||
LEFT JOIN purchase_request_items pri ON pri.purchase_request_id = pr.id
|
||||
{where}
|
||||
GROUP BY pr.id
|
||||
ORDER BY pr.date DESC
|
||||
LIMIT 100
|
||||
""", params)
|
||||
return json.dumps(rows, default=str, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_supplier_invoices(status: str = "") -> str:
|
||||
"""List supplier invoices. Optional status: unpaid|partial|paid"""
|
||||
where = "WHERE si.status = ?" if status else ""
|
||||
params = (status,) if status else ()
|
||||
rows = _rows(f"""
|
||||
SELECT si.id, si.invoice_number, s.name AS supplier,
|
||||
si.invoice_date, si.due_date, si.total_amount,
|
||||
si.paid_amount, si.status
|
||||
FROM supplier_invoices si
|
||||
JOIN suppliers s ON s.id = si.supplier_id
|
||||
{where}
|
||||
ORDER BY si.invoice_date DESC
|
||||
LIMIT 100
|
||||
""", params)
|
||||
return json.dumps(rows, default=str, indent=2)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Inventory Module
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
def get_items(category: str = "", active_only: bool = False) -> str:
|
||||
"""
|
||||
List inventory items.
|
||||
category: raw_material | wip | finished_good (blank = all)
|
||||
"""
|
||||
conditions = []
|
||||
params: list = []
|
||||
if category:
|
||||
conditions.append("category = ?")
|
||||
params.append(category)
|
||||
if active_only:
|
||||
conditions.append("is_active = 1")
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
rows = _rows(f"""
|
||||
SELECT id, item_code, item_name, category, unit_of_measure,
|
||||
cost_price, minimum_stock_level,
|
||||
CASE WHEN is_active THEN 'Active' ELSE 'Inactive' END AS status
|
||||
FROM items {where}
|
||||
ORDER BY category, item_name
|
||||
""", tuple(params))
|
||||
return json.dumps(rows, default=str, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_stock_levels(warehouse_id: int = 0, low_stock_only: bool = False) -> str:
|
||||
"""
|
||||
Current stock levels, optionally filtered by warehouse or showing only low-stock items.
|
||||
"""
|
||||
conditions = []
|
||||
params: list = []
|
||||
if warehouse_id:
|
||||
conditions.append("sl.warehouse_id = ?")
|
||||
params.append(warehouse_id)
|
||||
if low_stock_only:
|
||||
conditions.append("sl.quantity <= i.minimum_stock_level")
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
rows = _rows(f"""
|
||||
SELECT i.item_code, i.item_name, i.category, i.unit_of_measure,
|
||||
w.name AS warehouse, sl.quantity,
|
||||
i.minimum_stock_level,
|
||||
ROUND(sl.quantity * i.cost_price, 2) AS stock_value
|
||||
FROM stock_levels sl
|
||||
JOIN items i ON i.id = sl.item_id
|
||||
JOIN warehouses w ON w.id = sl.warehouse_id
|
||||
{where}
|
||||
ORDER BY i.category, i.item_name
|
||||
""", tuple(params))
|
||||
return json.dumps(rows, default=str, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_warehouses() -> str:
|
||||
"""List all warehouses."""
|
||||
rows = _rows("""
|
||||
SELECT w.id, w.code, w.name, w.location,
|
||||
CASE WHEN w.is_active THEN 'Active' ELSE 'Inactive' END AS status,
|
||||
COUNT(sl.id) AS item_types,
|
||||
ROUND(SUM(sl.quantity * i.cost_price), 2) AS total_value
|
||||
FROM warehouses w
|
||||
LEFT JOIN stock_levels sl ON sl.warehouse_id = w.id
|
||||
LEFT JOIN items i ON i.id = sl.item_id
|
||||
GROUP BY w.id
|
||||
ORDER BY w.name
|
||||
""")
|
||||
return json.dumps(rows, default=str, indent=2)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Production Module
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
def get_production_orders(status: str = "") -> str:
|
||||
"""
|
||||
List production orders.
|
||||
status: planned|in_progress|completed|cancelled
|
||||
"""
|
||||
where = "WHERE po.status = ?" if status else ""
|
||||
params = (status,) if status else ()
|
||||
rows = _rows(f"""
|
||||
SELECT po.id, po.order_number, i.item_name AS product,
|
||||
po.quantity_to_produce, po.quantity_produced,
|
||||
po.production_date, po.completion_date, po.status
|
||||
FROM production_orders po
|
||||
JOIN items i ON i.id = po.product_id
|
||||
{where}
|
||||
ORDER BY po.production_date DESC
|
||||
LIMIT 100
|
||||
""", params)
|
||||
return json.dumps(rows, default=str, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_bill_of_materials(product_id: int = 0) -> str:
|
||||
"""
|
||||
Show bill of materials (recipes). Pass product_id to filter to one product.
|
||||
"""
|
||||
where = "WHERE bom.product_id = ?" if product_id else ""
|
||||
params = (product_id,) if product_id else ()
|
||||
rows = _rows(f"""
|
||||
SELECT p.item_name AS product, r.item_name AS raw_material,
|
||||
bom.quantity_required, bom.unit_of_measure
|
||||
FROM bill_of_materials bom
|
||||
JOIN items p ON p.id = bom.product_id
|
||||
JOIN items r ON r.id = bom.raw_material_id
|
||||
{where}
|
||||
ORDER BY p.item_name, r.item_name
|
||||
""", params)
|
||||
return json.dumps(rows, default=str, indent=2)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Sales Module
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
def get_customers(active_only: bool = False) -> str:
|
||||
"""List all customers with outstanding balances."""
|
||||
where = "WHERE is_active = 1" if active_only else ""
|
||||
rows = _rows(f"""
|
||||
SELECT id, name, contact_person, email, phone,
|
||||
credit_limit, outstanding_balance,
|
||||
CASE WHEN is_active THEN 'Active' ELSE 'Inactive' END AS status
|
||||
FROM customers {where}
|
||||
ORDER BY name
|
||||
""")
|
||||
return json.dumps(rows, default=str, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_sales_orders(status: str = "") -> str:
|
||||
"""
|
||||
List sales orders.
|
||||
status: draft|confirmed|dispatched|invoiced|cancelled
|
||||
"""
|
||||
where = "WHERE so.status = ?" if status else ""
|
||||
params = (status,) if status else ()
|
||||
rows = _rows(f"""
|
||||
SELECT so.id, so.order_number, c.name AS customer,
|
||||
so.order_date, so.delivery_date,
|
||||
so.total_amount, so.status
|
||||
FROM sales_orders so
|
||||
JOIN customers c ON c.id = so.customer_id
|
||||
{where}
|
||||
ORDER BY so.order_date DESC
|
||||
LIMIT 100
|
||||
""", params)
|
||||
return json.dumps(rows, default=str, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_sales_invoices(status: str = "") -> str:
|
||||
"""List sales invoices. status: unpaid|partial|paid|cancelled"""
|
||||
where = "WHERE si.status = ?" if status else ""
|
||||
params = (status,) if status else ()
|
||||
rows = _rows(f"""
|
||||
SELECT si.id, si.invoice_number, c.name AS customer,
|
||||
si.invoice_date, si.due_date, si.total_amount,
|
||||
si.paid_amount, si.status
|
||||
FROM sales_invoices si
|
||||
JOIN customers c ON c.id = si.customer_id
|
||||
{where}
|
||||
ORDER BY si.invoice_date DESC
|
||||
LIMIT 100
|
||||
""", params)
|
||||
return json.dumps(rows, default=str, indent=2)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Dashboard / Summary
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
def get_dashboard_summary() -> str:
|
||||
"""
|
||||
High-level KPIs: inventory value, open POs, open sales orders,
|
||||
unpaid supplier invoices, unpaid sales invoices, active items/suppliers.
|
||||
"""
|
||||
with _conn() as conn:
|
||||
def scalar(sql, p=()):
|
||||
r = conn.execute(sql, p).fetchone()
|
||||
return r[0] if r else 0
|
||||
|
||||
summary = {
|
||||
"suppliers": scalar("SELECT COUNT(*) FROM suppliers WHERE is_active=1"),
|
||||
"items_active": scalar("SELECT COUNT(*) FROM items WHERE is_active=1"),
|
||||
"inventory_value": scalar("SELECT ROUND(SUM(sl.quantity*i.cost_price),2) FROM stock_levels sl JOIN items i ON i.id=sl.item_id"),
|
||||
"low_stock_items": scalar("SELECT COUNT(*) FROM stock_levels sl JOIN items i ON i.id=sl.item_id WHERE sl.quantity<=i.minimum_stock_level AND i.minimum_stock_level>0"),
|
||||
"open_purchase_orders": scalar("SELECT COUNT(*) FROM purchase_orders WHERE status IN ('draft','sent','partial')"),
|
||||
"pending_requests": scalar("SELECT COUNT(*) FROM purchase_requests WHERE status='pending'"),
|
||||
"open_production_orders": scalar("SELECT COUNT(*) FROM production_orders WHERE status IN ('planned','in_progress')"),
|
||||
"open_sales_orders": scalar("SELECT COUNT(*) FROM sales_orders WHERE status IN ('draft','confirmed')"),
|
||||
"unpaid_supplier_invoices": scalar("SELECT ROUND(SUM(total_amount-paid_amount),2) FROM supplier_invoices WHERE status IN ('unpaid','partial')"),
|
||||
"unpaid_sales_invoices": scalar("SELECT ROUND(SUM(total_amount-paid_amount),2) FROM sales_invoices WHERE status IN ('unpaid','partial')"),
|
||||
}
|
||||
return json.dumps(summary, default=str, indent=2)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"superpowers@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[compose.yaml]
|
||||
indent_size = 4
|
||||
65
.env.example
Normal file
65
.env.example
Normal file
@ -0,0 +1,65 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
@ -0,0 +1,169 @@
|
||||
<h2>Purchase Pipeline — Architecture</h2>
|
||||
<p class="subtitle">7 stages, 3 new tables, everything else reuses existing models</p>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:4px;">
|
||||
|
||||
<!-- Left: Stage map -->
|
||||
<div>
|
||||
<div class="label" style="margin-bottom:12px;">The 7 Stages</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:0;">
|
||||
|
||||
<!-- Stage 1 -->
|
||||
<div style="display:flex;align-items:stretch;gap:12px;">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#2563eb;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">1</div>
|
||||
<div style="width:2px;flex:1;background:#e2e8f0;margin:3px 0;"></div>
|
||||
</div>
|
||||
<div style="padding-bottom:16px;">
|
||||
<div style="font-size:13px;font-weight:700;color:#0f172a;">Purchase Request</div>
|
||||
<div style="font-size:11px;color:#64748b;">Operations team fills in items needed</div>
|
||||
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#eff6ff;color:#2563eb;">EXISTING · purchase_requests</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 2 -->
|
||||
<div style="display:flex;align-items:stretch;gap:12px;">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#7c3aed;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">2</div>
|
||||
<div style="width:2px;flex:1;background:#e2e8f0;margin:3px 0;"></div>
|
||||
</div>
|
||||
<div style="padding-bottom:16px;">
|
||||
<div style="font-size:13px;font-weight:700;color:#0f172a;">GM Digital Signature</div>
|
||||
<div style="font-size:11px;color:#64748b;">GM draws signature on canvas, saved as image</div>
|
||||
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#f5f3ff;color:#7c3aed;">NEW · purchase_signatures</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 3+4 -->
|
||||
<div style="display:flex;align-items:stretch;gap:12px;">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#0ea5e9;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">3</div>
|
||||
<div style="width:2px;flex:1;background:#e2e8f0;margin:3px 0;"></div>
|
||||
</div>
|
||||
<div style="padding-bottom:16px;">
|
||||
<div style="font-size:13px;font-weight:700;color:#0f172a;">Select Suppliers → Send RFQ Links</div>
|
||||
<div style="font-size:11px;color:#64748b;">Pick suppliers from list, system sends unique links via WhatsApp/Email</div>
|
||||
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#f0f9ff;color:#0ea5e9;">NEW · rfq_invitations</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 4 -->
|
||||
<div style="display:flex;align-items:stretch;gap:12px;">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#f59e0b;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">4</div>
|
||||
<div style="width:2px;flex:1;background:#e2e8f0;margin:3px 0;"></div>
|
||||
</div>
|
||||
<div style="padding-bottom:16px;">
|
||||
<div style="font-size:13px;font-weight:700;color:#0f172a;">Suppliers Submit Quotes</div>
|
||||
<div style="font-size:11px;color:#64748b;">Each supplier opens their private link, fills price + terms, submits</div>
|
||||
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#fffbeb;color:#b45309;">NEW · supplier_quotes + quote_items</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 5 -->
|
||||
<div style="display:flex;align-items:stretch;gap:12px;">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#f59e0b;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">5</div>
|
||||
<div style="width:2px;flex:1;background:#e2e8f0;margin:3px 0;"></div>
|
||||
</div>
|
||||
<div style="padding-bottom:16px;">
|
||||
<div style="font-size:13px;font-weight:700;color:#0f172a;">Quote Comparison & Award</div>
|
||||
<div style="font-size:11px;color:#64748b;">Side-by-side table, team picks winner, records decision reason</div>
|
||||
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#fffbeb;color:#b45309;">NEW · supplier_quotes (awarded flag)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 6 -->
|
||||
<div style="display:flex;align-items:stretch;gap:12px;">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#16a34a;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">6</div>
|
||||
<div style="width:2px;flex:1;background:#e2e8f0;margin:3px 0;"></div>
|
||||
</div>
|
||||
<div style="padding-bottom:16px;">
|
||||
<div style="font-size:13px;font-weight:700;color:#0f172a;">Issue LPO</div>
|
||||
<div style="font-size:11px;color:#64748b;">Create & send Local Purchase Order to winning supplier</div>
|
||||
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#f0fdf4;color:#16a34a;">EXISTING · purchase_orders (LPO)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 7 -->
|
||||
<div style="display:flex;align-items:stretch;gap:12px;">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#16a34a;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">7</div>
|
||||
<div style="width:2px;flex:1;background:#e2e8f0;margin:3px 0;"></div>
|
||||
</div>
|
||||
<div style="padding-bottom:16px;">
|
||||
<div style="font-size:13px;font-weight:700;color:#0f172a;">Receive Materials at Site</div>
|
||||
<div style="font-size:11px;color:#64748b;">GRN created — each item flagged Inventory or Consumable</div>
|
||||
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#f0fdf4;color:#16a34a;">EXISTING · goods_receipt_notes + grn_items</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stage 8 -->
|
||||
<div style="display:flex;align-items:stretch;gap:12px;">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;">
|
||||
<div style="width:32px;height:32px;border-radius:50%;background:#0f172a;color:#fff;font-weight:700;font-size:13px;display:flex;align-items:center;justify-content:center;flex-shrink:0;">8</div>
|
||||
</div>
|
||||
<div style="padding-bottom:4px;">
|
||||
<div style="font-size:13px;font-weight:700;color:#0f172a;">Issue Payment / Cheque</div>
|
||||
<div style="font-size:11px;color:#64748b;">Record cheque number, bank, amount, payment date</div>
|
||||
<div style="display:inline-block;margin-top:4px;padding:2px 8px;border-radius:20px;font-size:10px;font-weight:600;background:#f1f5f9;color:#475569;">EXISTING · supplier_payments</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: New tables + public portal -->
|
||||
<div>
|
||||
<div class="label" style="margin-bottom:12px;">3 New Database Tables</div>
|
||||
|
||||
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:14px;margin-bottom:12px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#7c3aed;margin-bottom:8px;">purchase_signatures</div>
|
||||
<div style="font-size:11px;color:#475569;font-family:monospace;line-height:1.8;">
|
||||
id<br>purchase_request_id → FK<br>signed_by → FK users<br>signature_image → base64 png<br>signed_at → timestamp<br>ip_address
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:14px;margin-bottom:12px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#0ea5e9;margin-bottom:8px;">rfq_invitations</div>
|
||||
<div style="font-size:11px;color:#475569;font-family:monospace;line-height:1.8;">
|
||||
id<br>purchase_request_id → FK<br>supplier_id → FK<br>token → unique 64-char hex<br>channel → email|whatsapp|both<br>sent_at, opened_at, expires_at<br>status → pending|opened|submitted|declined
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;padding:14px;margin-bottom:16px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#f59e0b;margin-bottom:8px;">supplier_quotes + supplier_quote_items</div>
|
||||
<div style="font-size:11px;color:#475569;font-family:monospace;line-height:1.8;">
|
||||
id<br>rfq_invitation_id → FK<br>submitted_at<br>lead_time_days<br>payment_terms<br>notes<br>total_amount<br>is_awarded → boolean<br>award_reason<br>── items ──<br>description, unit, qty, unit_price, total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="label" style="margin-bottom:8px;">Public Portal (no auth)</div>
|
||||
<div style="background:#fefce8;border:1px solid #fde68a;border-radius:12px;padding:14px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#92400e;margin-bottom:6px;">🔗 /rfq/{token}</div>
|
||||
<div style="font-size:11px;color:#78350f;line-height:1.7;">
|
||||
• Outside auth middleware — no login needed<br>
|
||||
• Token validated on every request<br>
|
||||
• Expires 7 days after sending<br>
|
||||
• Can only be submitted once<br>
|
||||
• Shows your company name + item list<br>
|
||||
• Supplier fills price/terms, submits<br>
|
||||
• Confirmation screen shown after submit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:12px;padding:14px;">
|
||||
<div style="font-size:12px;font-weight:700;color:#15803d;margin-bottom:6px;">Existing models — minor additions only</div>
|
||||
<div style="font-size:11px;color:#166534;line-height:1.7;">
|
||||
purchase_requests → add <strong>stage</strong> column<br>
|
||||
grn_items → add <strong>type</strong> (inventory|consumable)<br>
|
||||
purchase_orders → relabel as LPO in UI only
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="subtitle" style="margin-top:16px;">Does this architecture look right? Reply in the terminal.</p>
|
||||
@ -0,0 +1,127 @@
|
||||
<h2>How should the purchase pipeline tracker look?</h2>
|
||||
<p class="subtitle">Each purchase moves through stages like a delivery. Which style fits your team best?</p>
|
||||
|
||||
<div class="cards">
|
||||
|
||||
<!-- Option A: Horizontal progress bar (package tracking style) -->
|
||||
<div class="card" data-choice="a" onclick="toggleSelect(this)">
|
||||
<div class="card-image" style="padding:18px;background:#f8fafc;">
|
||||
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">PO-2024-001 · Steel Pipes · BD 4,200</div>
|
||||
<div style="display:flex;align-items:center;gap:0;position:relative;margin-bottom:8px;">
|
||||
<!-- Steps -->
|
||||
<div style="display:flex;align-items:center;width:100%;">
|
||||
<div style="text-align:center;flex:1;">
|
||||
<div style="width:28px;height:28px;border-radius:50%;background:#2563eb;color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;margin:0 auto 4px;">✓</div>
|
||||
<div style="font-size:9px;color:#2563eb;font-weight:600;">Request</div>
|
||||
</div>
|
||||
<div style="flex:1;height:2px;background:#2563eb;margin-bottom:16px;"></div>
|
||||
<div style="text-align:center;flex:1;">
|
||||
<div style="width:28px;height:28px;border-radius:50%;background:#2563eb;color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;margin:0 auto 4px;">✓</div>
|
||||
<div style="font-size:9px;color:#2563eb;font-weight:600;">GM Sign</div>
|
||||
</div>
|
||||
<div style="flex:1;height:2px;background:#2563eb;margin-bottom:16px;"></div>
|
||||
<div style="text-align:center;flex:1;">
|
||||
<div style="width:28px;height:28px;border-radius:50%;background:#2563eb;color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;margin:0 auto 4px;">✓</div>
|
||||
<div style="font-size:9px;color:#2563eb;font-weight:600;">Suppliers</div>
|
||||
</div>
|
||||
<div style="flex:1;height:2px;background:#f59e0b;margin-bottom:16px;"></div>
|
||||
<div style="text-align:center;flex:1;">
|
||||
<div style="width:28px;height:28px;border-radius:50%;background:#f59e0b;color:#fff;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;margin:0 auto 4px;">4</div>
|
||||
<div style="font-size:9px;color:#f59e0b;font-weight:700;">Quotes ◄</div>
|
||||
</div>
|
||||
<div style="flex:1;height:2px;background:#e2e8f0;margin-bottom:16px;"></div>
|
||||
<div style="text-align:center;flex:1;">
|
||||
<div style="width:28px;height:28px;border-radius:50%;background:#e2e8f0;color:#94a3b8;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;margin:0 auto 4px;">5</div>
|
||||
<div style="font-size:9px;color:#94a3b8;">LPO</div>
|
||||
</div>
|
||||
<div style="flex:1;height:2px;background:#e2e8f0;margin-bottom:16px;"></div>
|
||||
<div style="text-align:center;flex:1;">
|
||||
<div style="width:28px;height:28px;border-radius:50%;background:#e2e8f0;color:#94a3b8;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;margin:0 auto 4px;">6</div>
|
||||
<div style="font-size:9px;color:#94a3b8;">Receive</div>
|
||||
</div>
|
||||
<div style="flex:1;height:2px;background:#e2e8f0;margin-bottom:16px;"></div>
|
||||
<div style="text-align:center;flex:1;">
|
||||
<div style="width:28px;height:28px;border-radius:50%;background:#e2e8f0;color:#94a3b8;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center;margin:0 auto 4px;">7</div>
|
||||
<div style="font-size:9px;color:#94a3b8;">Payment</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:6px 10px;font-size:11px;color:#92400e;font-weight:600;">
|
||||
⏳ Waiting for supplier quotes — 2 of 3 received
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>A — Horizontal Pipeline</h3>
|
||||
<p>Steps across the top like a progress bar. Current stage highlighted, done steps checked. Great for seeing the full journey at a glance on the list page.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Option B: Vertical timeline (delivery tracking style) -->
|
||||
<div class="card" data-choice="b" onclick="toggleSelect(this)">
|
||||
<div class="card-image" style="padding:18px;background:#f8fafc;">
|
||||
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.06em;margin-bottom:14px;">PO-2024-001 · Steel Pipes</div>
|
||||
<div style="position:relative;padding-left:28px;">
|
||||
<!-- vertical line -->
|
||||
<div style="position:absolute;left:10px;top:4px;bottom:4px;width:2px;background:linear-gradient(to bottom,#2563eb 45%,#e2e8f0 45%);border-radius:2px;"></div>
|
||||
<div style="margin-bottom:10px;position:relative;">
|
||||
<div style="position:absolute;left:-22px;width:14px;height:14px;border-radius:50%;background:#2563eb;border:2px solid #fff;box-shadow:0 0 0 2px #2563eb;"></div>
|
||||
<div style="font-size:11px;font-weight:700;color:#2563eb;">Request Created</div>
|
||||
<div style="font-size:10px;color:#94a3b8;">May 12 · Operations Team</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;position:relative;">
|
||||
<div style="position:absolute;left:-22px;width:14px;height:14px;border-radius:50%;background:#2563eb;border:2px solid #fff;box-shadow:0 0 0 2px #2563eb;"></div>
|
||||
<div style="font-size:11px;font-weight:700;color:#2563eb;">GM Approved</div>
|
||||
<div style="font-size:10px;color:#94a3b8;">May 13 · Ahmed Al-Rashid</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;position:relative;">
|
||||
<div style="position:absolute;left:-22px;width:14px;height:14px;border-radius:50%;background:#f59e0b;border:2px solid #fff;box-shadow:0 0 0 2px #f59e0b;"></div>
|
||||
<div style="font-size:11px;font-weight:700;color:#f59e0b;">Awaiting Quotes (2/3)</div>
|
||||
<div style="font-size:10px;color:#94a3b8;">Sent to 3 suppliers · May 14</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;position:relative;opacity:.45;">
|
||||
<div style="position:absolute;left:-22px;width:14px;height:14px;border-radius:50%;background:#e2e8f0;border:2px solid #fff;"></div>
|
||||
<div style="font-size:11px;font-weight:600;color:#64748b;">Quote Comparison</div>
|
||||
</div>
|
||||
<div style="position:relative;opacity:.45;">
|
||||
<div style="position:absolute;left:-22px;width:14px;height:14px;border-radius:50%;background:#e2e8f0;border:2px solid #fff;"></div>
|
||||
<div style="font-size:11px;font-weight:600;color:#64748b;">Issue LPO → Receive → Pay</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>B — Vertical Timeline</h3>
|
||||
<p>Like a courier tracking page — each step logged with date and actor. Best for the detail view of a single purchase. Shows history clearly.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Option C: Both combined -->
|
||||
<div class="card" data-choice="c" onclick="toggleSelect(this)">
|
||||
<div class="card-image" style="padding:18px;background:#f8fafc;">
|
||||
<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.06em;margin-bottom:10px;">PO-2024-001 · Steel Pipes · BD 4,200</div>
|
||||
<!-- Compact horizontal on list -->
|
||||
<div style="display:flex;gap:3px;margin-bottom:10px;">
|
||||
<div style="flex:1;height:6px;border-radius:3px;background:#2563eb;"></div>
|
||||
<div style="flex:1;height:6px;border-radius:3px;background:#2563eb;"></div>
|
||||
<div style="flex:1;height:6px;border-radius:3px;background:#2563eb;"></div>
|
||||
<div style="flex:1;height:6px;border-radius:3px;background:#f59e0b;animation:pulse 1.5s infinite;"></div>
|
||||
<div style="flex:1;height:6px;border-radius:3px;background:#e2e8f0;"></div>
|
||||
<div style="flex:1;height:6px;border-radius:3px;background:#e2e8f0;"></div>
|
||||
<div style="flex:1;height:6px;border-radius:3px;background:#e2e8f0;"></div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:9px;color:#94a3b8;margin-bottom:10px;">
|
||||
<span>Request</span><span>GM</span><span>RFQ</span><span style="color:#f59e0b;font-weight:700;">Quotes▲</span><span>LPO</span><span>Receive</span><span>Pay</span>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:8px 12px;font-size:11px;display:flex;align-items:center;justify-content:space-between;">
|
||||
<span style="color:#f59e0b;font-weight:600;">⏳ Quotes: 2 of 3 received</span>
|
||||
<span style="color:#2563eb;font-weight:600;cursor:pointer;">Open →</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>C — Combined (Recommended)</h3>
|
||||
<p>Compact coloured bar on the list page, full vertical timeline inside the detail view. Best of both worlds — scan many purchases quickly, then drill in for full history.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<p class="subtitle" style="margin-top:16px;">Click a card to select, then reply in the terminal.</p>
|
||||
@ -0,0 +1,3 @@
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||
<p class="subtitle">Continuing in terminal…</p>
|
||||
</div>
|
||||
@ -0,0 +1,3 @@
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||
<p class="subtitle">Writing the spec and implementation plan…</p>
|
||||
</div>
|
||||
@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1779110714918}
|
||||
1
.superpowers/brainstorm/1542-1779106932/state/server.pid
Normal file
1
.superpowers/brainstorm/1542-1779106932/state/server.pid
Normal file
@ -0,0 +1 @@
|
||||
1542
|
||||
362
CLAUDE.md
Normal file
362
CLAUDE.md
Normal file
@ -0,0 +1,362 @@
|
||||
# OperationModule — SteelERP
|
||||
|
||||
Manufacturing & Trading ERP. Laravel 12 / PHP 8.2. SQLite. Four modules: Purchase, Inventory, Production, Sales.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
composer install && npm install
|
||||
cp .env.example .env && php artisan key:generate
|
||||
php artisan migrate --seed
|
||||
npm run dev # Vite dev server (keep running)
|
||||
php artisan serve # http://localhost:8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Framework | Laravel 12, PHP 8.2 |
|
||||
| Frontend | Tailwind CSS v3 (JIT), Alpine.js v3, Vite 7 |
|
||||
| Database | SQLite — `database/database.sqlite` |
|
||||
| Auth | Laravel Breeze (email+password, verified) |
|
||||
| RBAC | spatie/laravel-permission v6 — middleware: `role`, `permission`, `role_or_permission` |
|
||||
| PDF | barryvdh/laravel-dompdf v3 |
|
||||
| Excel | phpoffice/phpspreadsheet v5 |
|
||||
| Dev tools | Pint, Pail, Sail, PHPUnit 11 |
|
||||
|
||||
**Roles (seeded):** Admin, Accounts, Store Manager, Production Manager, Sales Manager
|
||||
|
||||
---
|
||||
|
||||
## Controllers — `app/Http/Controllers/`
|
||||
|
||||
```
|
||||
Controller.php
|
||||
DashboardController.php
|
||||
ProfileController.php
|
||||
Auth/
|
||||
AuthenticatedSessionController.php
|
||||
ConfirmablePasswordController.php
|
||||
EmailVerificationNotificationController.php
|
||||
EmailVerificationPromptController.php
|
||||
NewPasswordController.php
|
||||
PasswordController.php
|
||||
PasswordResetLinkController.php
|
||||
RegisteredUserController.php
|
||||
VerifyEmailController.php
|
||||
Purchase/
|
||||
SupplierController.php import, downloadTemplate, exportPdf + CRUD
|
||||
PurchaseRequestController.php + approve, reject, print
|
||||
PurchaseOrderController.php
|
||||
GoodsReceiptNoteController.php + confirm
|
||||
SupplierInvoiceController.php
|
||||
SupplierPaymentController.php
|
||||
Inventory/
|
||||
ItemController.php import, downloadTemplate, exportPdf + CRUD
|
||||
WarehouseController.php
|
||||
StockMovementController.php
|
||||
StockReportController.php summary, movement, lowStock, valuation
|
||||
Production/
|
||||
ProductionOrderController.php + start, complete
|
||||
BillOfMaterialController.php
|
||||
MaterialIssueController.php
|
||||
ProductionOutputController.php
|
||||
Sales/
|
||||
CustomerController.php
|
||||
SalesOrderController.php + confirm
|
||||
DeliveryNoteController.php + dispatch
|
||||
SalesInvoiceController.php
|
||||
PaymentReceiptController.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Models — `app/Models/`
|
||||
|
||||
```
|
||||
User.php
|
||||
Supplier.php SupplierInvoice.php SupplierPayment.php
|
||||
PurchaseRequest.php PurchaseRequestItem.php
|
||||
PurchaseOrder.php PurchaseOrderItem.php
|
||||
GoodsReceiptNote.php GrnItem.php
|
||||
Item.php Warehouse.php StockLevel.php StockMovement.php
|
||||
ProductionOrder.php BillOfMaterial.php MaterialIssue.php
|
||||
ProductionOutput.php ProductionCost.php
|
||||
Customer.php SalesOrder.php SalesOrderItem.php
|
||||
DeliveryNote.php DeliveryNoteItem.php
|
||||
SalesInvoice.php PaymentReceipt.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Services — `app/Services/`
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SupplierImportService.php` | Excel import — detects MRF vs template format, skips duplicates |
|
||||
| `ItemImportService.php` | Excel import — detects Forkoll vs template format, skips duplicates |
|
||||
|
||||
---
|
||||
|
||||
## Artisan Commands — `app/Console/Commands/`
|
||||
|
||||
```bash
|
||||
php artisan suppliers:import "path/to/file.xlsx" [--dry-run]
|
||||
php artisan suppliers:template [--output=path] # → storage/app/suppliers_template.xlsx
|
||||
php artisan items:template [--output=path] # → storage/app/items_template.xlsx
|
||||
```
|
||||
|
||||
Files: `ImportSuppliers.php`, `GenerateSupplierTemplate.php`, `GenerateItemTemplate.php`
|
||||
|
||||
---
|
||||
|
||||
## Routes — `routes/web.php`
|
||||
|
||||
All protected by `['auth', 'verified']`. Prefix groups:
|
||||
|
||||
### Purchase — `prefix('purchase')->name('purchase.')`
|
||||
```
|
||||
GET/POST suppliers purchase.suppliers.*
|
||||
POST suppliers/import purchase.suppliers.import ← must be BEFORE resource
|
||||
GET suppliers/template purchase.suppliers.template ← must be BEFORE resource
|
||||
GET suppliers/export-pdf purchase.suppliers.export-pdf ← must be BEFORE resource
|
||||
GET/POST requests purchase.requests.*
|
||||
PATCH requests/{id}/approve purchase.requests.approve
|
||||
PATCH requests/{id}/reject purchase.requests.reject
|
||||
GET requests/{id}/print purchase.requests.print
|
||||
GET/POST orders purchase.orders.*
|
||||
GET/POST grns purchase.grns.*
|
||||
PATCH grns/{id}/confirm purchase.grns.confirm
|
||||
GET/POST invoices purchase.invoices.*
|
||||
GET/POST payments purchase.payments.*
|
||||
```
|
||||
|
||||
### Inventory — `prefix('inventory')->name('inventory.')`
|
||||
```
|
||||
GET/POST items inventory.items.*
|
||||
POST items/import inventory.items.import ← must be BEFORE resource
|
||||
GET items/template inventory.items.template ← must be BEFORE resource
|
||||
GET items/export-pdf inventory.items.export-pdf ← must be BEFORE resource
|
||||
GET/POST warehouses inventory.warehouses.*
|
||||
GET/POST movements inventory.movements.*
|
||||
GET reports/summary inventory.reports.summary
|
||||
GET reports/movement inventory.reports.movement
|
||||
GET reports/low-stock inventory.reports.low-stock
|
||||
GET reports/valuation inventory.reports.valuation
|
||||
```
|
||||
|
||||
### Production — `prefix('production')->name('production.')`
|
||||
```
|
||||
GET/POST orders production.orders.*
|
||||
PATCH orders/{id}/start production.orders.start
|
||||
PATCH orders/{id}/complete production.orders.complete
|
||||
GET/POST bom production.bom.*
|
||||
GET/POST material-issues production.material-issues.*
|
||||
GET/POST outputs production.outputs.*
|
||||
```
|
||||
|
||||
### Sales — `prefix('sales')->name('sales.')`
|
||||
```
|
||||
GET/POST customers sales.customers.*
|
||||
GET/POST orders sales.orders.*
|
||||
PATCH orders/{id}/confirm sales.orders.confirm
|
||||
GET/POST delivery-notes sales.delivery-notes.*
|
||||
PATCH delivery-notes/{id}/dispatch sales.delivery-notes.dispatch
|
||||
GET/POST invoices sales.invoices.*
|
||||
GET/POST payments sales.payments.*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Views — `resources/views/`
|
||||
|
||||
```
|
||||
dashboard.blade.php
|
||||
welcome.blade.php
|
||||
layouts/
|
||||
app.blade.php main layout (sidebar + topbar)
|
||||
navigation.blade.php
|
||||
guest.blade.php
|
||||
components/ (Breeze defaults: modal, dropdown, buttons, inputs, etc.)
|
||||
auth/ (login, register, forgot-password, reset-password, verify-email, confirm-password)
|
||||
profile/edit.blade.php + partials/
|
||||
|
||||
purchase/
|
||||
suppliers/ index, create, edit, pdf
|
||||
requests/ index, create, edit, show
|
||||
orders/ index, create, edit, show
|
||||
grns/ index, create, show
|
||||
invoices/ index, create, edit
|
||||
payments/ index, create
|
||||
|
||||
inventory/
|
||||
items/ index, create, edit, pdf
|
||||
warehouses/ index, create, edit
|
||||
movements/ index, create
|
||||
reports/ summary, movement, low-stock, valuation
|
||||
|
||||
production/
|
||||
orders/ index, create, edit, show
|
||||
bom/ index, create, edit
|
||||
material-issues/ index
|
||||
outputs/ index
|
||||
|
||||
sales/
|
||||
customers/ index, create, edit
|
||||
orders/ index, create, edit, show
|
||||
delivery-notes/ index, create, edit
|
||||
invoices/ index, create, edit
|
||||
payments/ index, create
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database — `database/`
|
||||
|
||||
**Driver:** SQLite — `database/database.sqlite`
|
||||
|
||||
### Migrations (29 total)
|
||||
```
|
||||
users, cache, jobs (Laravel defaults)
|
||||
permission_tables (Spatie)
|
||||
suppliers, items, warehouses, stock_levels, stock_movements
|
||||
purchase_orders, purchase_order_items, goods_receipt_notes, purchase_requests
|
||||
grn_items, supplier_invoices, supplier_payments
|
||||
production_orders, bill_of_materials, material_issues
|
||||
production_outputs, production_costs
|
||||
customers, sales_orders, sales_order_items, delivery_notes
|
||||
delivery_note_items, sales_invoices, payment_receipts
|
||||
purchase_request_items
|
||||
```
|
||||
|
||||
### Seeders
|
||||
`database/seeders/DatabaseSeeder.php` — creates roles (Admin, Accounts, Store Manager, Production Manager, Sales Manager)
|
||||
|
||||
---
|
||||
|
||||
## MCP Server — `.claude/mcp-server.py`
|
||||
|
||||
Read-only SQLite access. 16 tools:
|
||||
|
||||
```
|
||||
list_tables describe_table(table)
|
||||
run_query(sql) — SELECT/WITH only, max 200 rows
|
||||
|
||||
get_suppliers(active_only) get_purchase_orders(status)
|
||||
get_purchase_requests(status) get_supplier_invoices(status)
|
||||
|
||||
get_items(category,active) get_stock_levels(warehouse_id,low_stock_only)
|
||||
get_warehouses()
|
||||
|
||||
get_production_orders(status) get_bill_of_materials(product_id)
|
||||
|
||||
get_customers(active_only) get_sales_orders(status)
|
||||
get_sales_invoices(status) get_dashboard_summary()
|
||||
```
|
||||
|
||||
**Activation:** run `claude mcp add operation-module-db python -- .claude/mcp-server.py` once, then restart Claude Code. Use these tools before reading PHP files or running artisan for data questions.
|
||||
|
||||
---
|
||||
|
||||
## Critical Gotchas
|
||||
|
||||
### 1. Tailwind JIT — use inline styles for modals/dynamic classes
|
||||
Vite JIT only compiles classes found in scanned source files at build time. Classes like `max-w-lg`, `max-w-md`, arbitrary colors used only in a modal or added dynamically **will not appear in the compiled CSS**. Always use `style="..."` inline for modal widths, custom colors, and one-off layout values.
|
||||
|
||||
### 2. PhpSpreadsheet v5 — `getCellByColumnAndRow()` removed
|
||||
Use `Coordinate::stringFromColumnIndex($col) . $row` then `$sheet->getCell($coord)` instead:
|
||||
```php
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
$val = $sheet->getCell(Coordinate::stringFromColumnIndex($col) . $row)->getValue();
|
||||
```
|
||||
|
||||
### 3. Custom routes BEFORE Route::resource()
|
||||
`Route::resource('suppliers', ...)` registers `{supplier}` wildcard that captures `import`, `template`, `export-pdf`. Custom routes MUST be declared first:
|
||||
```php
|
||||
Route::post('suppliers/import', ...); // first
|
||||
Route::get('suppliers/template', ...); // first
|
||||
Route::get('suppliers/export-pdf', ...); // first
|
||||
Route::resource('suppliers', ...); // last
|
||||
```
|
||||
|
||||
### 4. MRFMI supplier import — contact fields are always empty
|
||||
The MRFMI price-comparison Excel file only contains supplier names in row 4 as column headers. There is no email, phone, or contact data anywhere in the file. This is expected — not a bug.
|
||||
|
||||
### 5. Forkoll import — "DESCRIPTION" header row
|
||||
The Forkoll inventory file has a header row (col A = SI.NO, col B = DESCRIPTION). `ItemImportService` skips col B values in `$headerWords = ['description', 'item name', 'item_name', 'material']` to avoid importing headers as items.
|
||||
|
||||
### 6. Search bars — always client-side, never page-refreshing
|
||||
All search/filter inputs MUST filter records instantly using JavaScript. No `?search=` URL params, no form submissions, no `debounce` + fetch, no page reloads of any kind.
|
||||
|
||||
**The pattern to follow (used on the Suppliers index):**
|
||||
```javascript
|
||||
var searchInput = document.getElementById('my-search');
|
||||
var rows = document.querySelectorAll('.data-row');
|
||||
var countEl = document.getElementById('search-count');
|
||||
var total = rows.length;
|
||||
|
||||
searchInput.addEventListener('input', function() {
|
||||
var q = this.value.trim().toLowerCase();
|
||||
var visible = 0;
|
||||
rows.forEach(function(row) {
|
||||
var match = !q || row.textContent.toLowerCase().indexOf(q) !== -1;
|
||||
row.classList.toggle('hidden-by-search', !match);
|
||||
if (match) visible++;
|
||||
});
|
||||
countEl.textContent = q ? (visible + ' of ' + total) : total;
|
||||
});
|
||||
```
|
||||
- All records are loaded on page load — the server query returns everything, JS does the filtering
|
||||
- Add `.hidden-by-search { display:none; }` to the page `<style>` block
|
||||
- Show a live count ("12 of 47 suppliers") next to the search input
|
||||
- Show a "No results" message when `visible === 0 && q !== ''`
|
||||
- Never add `?search=` query params or submit a form for search
|
||||
|
||||
### 7. Never use `alert()`, `confirm()`, or `prompt()` — use toasts and modals
|
||||
These native browser dialogs are ugly, block the thread, and cannot be styled. They are **banned** across the entire project.
|
||||
- Use `showToast('msg', 'type')` for notifications instead of `alert()`
|
||||
- Use a custom modal (like the delete confirmation modal) instead of `confirm()`
|
||||
- Use a styled modal with an `<input>` instead of `prompt()`
|
||||
|
||||
### 8. Route parameter names must never be `{request}`
|
||||
Laravel injects `Illuminate\Http\Request` by type-hint into `$request`. If a route parameter is also named `{request}`, implicit model binding silently fails — the model parameter receives null/empty, causing NOT NULL violations or UrlGenerationException when generating URLs from that model.
|
||||
|
||||
**Rule:** Always name route parameters after the model, matching the controller variable name exactly:
|
||||
```php
|
||||
// WRONG — {request} clashes with Request $request injection
|
||||
Route::post('requests/{request}/sign', [PurchaseSignatureController::class, 'store']);
|
||||
// store(Request $request, PurchaseRequest $purchaseRequest) — $purchaseRequest gets null
|
||||
|
||||
// CORRECT
|
||||
Route::post('requests/{purchaseRequest}/sign', [PurchaseSignatureController::class, 'store']);
|
||||
// store(Request $request, PurchaseRequest $purchaseRequest) — binding works
|
||||
```
|
||||
|
||||
**Route::resource also generates `{request}` for a resource named `requests`.** Always override it:
|
||||
```php
|
||||
// WRONG
|
||||
Route::resource('requests', PurchaseRequestController::class);
|
||||
// Generates {request} — clashes with $request injection in every method
|
||||
|
||||
// CORRECT
|
||||
Route::resource('requests', PurchaseRequestController::class)->parameters(['requests' => 'purchaseRequest']);
|
||||
// Generates {purchaseRequest} — matches $purchaseRequest in controller methods
|
||||
```
|
||||
Use `{purchaseRequest}`, `{purchaseOrder}`, `{supplier}`, etc. — never `{request}`.
|
||||
|
||||
### 9. Status notifications — always use toasts, never inline banners
|
||||
All success/error/info/warning messages MUST be displayed as toasts, not as `<div>` banners inside the page content. The global toast system is wired into `layouts/app.blade.php` and fires automatically from Laravel session flash keys (`success`, `error`, `info`, `warning`). In controllers, use:
|
||||
```php
|
||||
return redirect()->route('...')->with('success', 'Done.');
|
||||
return redirect()->route('...')->with('error', 'Something failed.');
|
||||
```
|
||||
To trigger a toast from JavaScript (e.g. after an in-page action), call:
|
||||
```javascript
|
||||
showToast('Message text', 'success'); // types: success | error | info | warn
|
||||
```
|
||||
**Never** add inline `@if(session('success'))` banner divs to individual views — the layout handles all of them. The toast appears bottom-right, auto-dismisses after 4 s, has a shrinking progress bar, and can be clicked or ×-closed early.
|
||||
59
README.md
Normal file
59
README.md
Normal file
@ -0,0 +1,59 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
||||
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
## Laravel Sponsors
|
||||
|
||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||
|
||||
### Premium Partners
|
||||
|
||||
- **[Vehikl](https://vehikl.com)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[64 Robots](https://64robots.com)**
|
||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||
- **[Redberry](https://redberry.international/laravel-development)**
|
||||
- **[Active Logic](https://activelogic.com)**
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
101
app/Console/Commands/GenerateItemTemplate.php
Normal file
101
app/Console/Commands/GenerateItemTemplate.php
Normal file
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
|
||||
class GenerateItemTemplate extends Command
|
||||
{
|
||||
protected $signature = 'items:template {--output= : Save path}';
|
||||
protected $description = 'Generate a clean Excel template for importing inventory items';
|
||||
|
||||
private array $columns = [
|
||||
['item_name *', 'SUPERBOND F5 White 25kg', 36],
|
||||
['unit_of_measure', 'BAG', 16],
|
||||
['category', 'finished_good', 20],
|
||||
['cost_price', '0.00', 14],
|
||||
['minimum_stock_level', '0', 20],
|
||||
['description', '', 36],
|
||||
['is_active', 'yes', 12],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$outputPath = $this->option('output') ?? storage_path('app/items_template.xlsx');
|
||||
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('Items');
|
||||
|
||||
// Headers
|
||||
foreach ($this->columns as $i => [$label]) {
|
||||
$sheet->getCell(Coordinate::stringFromColumnIndex($i + 1) . '1')->setValue($label);
|
||||
}
|
||||
|
||||
$lastCol = Coordinate::stringFromColumnIndex(count($this->columns));
|
||||
$sheet->getStyle("A1:{$lastCol}1")->applyFromArray([
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF'], 'size' => 11],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '0f172a']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'FFFFFF']]],
|
||||
]);
|
||||
$sheet->getRowDimension(1)->setRowHeight(22);
|
||||
|
||||
// Example rows
|
||||
$examples = [
|
||||
['SUPERBOND F5 White 25kg', 'BAG', 'finished_good', '0.00', '0', '', 'yes'],
|
||||
['SUPERBOND F5 Grey 25kg', 'BAG', 'finished_good', '0.00', '0', '', 'yes'],
|
||||
['Pentaproof 20 P', 'KG', 'raw_material', '1.09', '0', '', 'yes'],
|
||||
['JOLES YELLOW OCHRE', 'KG', 'raw_material', '1.32', '0', '', 'yes'],
|
||||
['Silica Sand', 'KG', 'raw_material', '0.01', '0', '', 'yes'],
|
||||
];
|
||||
|
||||
foreach ($examples as $rowIdx => $values) {
|
||||
$excelRow = $rowIdx + 2;
|
||||
foreach ($values as $colIdx => $value) {
|
||||
$sheet->getCell(Coordinate::stringFromColumnIndex($colIdx + 1) . $excelRow)->setValue($value);
|
||||
}
|
||||
}
|
||||
|
||||
$lastDataRow = count($examples) + 1;
|
||||
$sheet->getStyle("A2:{$lastCol}{$lastDataRow}")->applyFromArray([
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'F8FAFC']],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'CBD5E1']]],
|
||||
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
|
||||
]);
|
||||
|
||||
// Notes
|
||||
$notesRow = $lastDataRow + 2;
|
||||
$sheet->getCell("A{$notesRow}")->setValue(
|
||||
'* Required. category must be: finished_good, raw_material, or wip. Items with "Not in Use" in the name are imported as inactive. Delete example rows before importing.'
|
||||
);
|
||||
$sheet->mergeCells("A{$notesRow}:{$lastCol}{$notesRow}");
|
||||
$sheet->getStyle("A{$notesRow}")->applyFromArray([
|
||||
'font' => ['italic' => true, 'color' => ['rgb' => '6B7280'], 'size' => 9],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'FEF9C3']],
|
||||
]);
|
||||
|
||||
// Column widths
|
||||
foreach ($this->columns as $i => [, , $width]) {
|
||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($i + 1))->setWidth($width);
|
||||
}
|
||||
|
||||
$dir = dirname($outputPath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
(new Xlsx($spreadsheet))->save($outputPath);
|
||||
|
||||
$this->info("Template saved to: {$outputPath}");
|
||||
$this->line("Usage: php artisan items:import \"<path-to-file.xlsx>\"");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
122
app/Console/Commands/GenerateSupplierTemplate.php
Normal file
122
app/Console/Commands/GenerateSupplierTemplate.php
Normal file
@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
|
||||
class GenerateSupplierTemplate extends Command
|
||||
{
|
||||
protected $signature = 'suppliers:template
|
||||
{--output= : Save path (default: storage/app/suppliers_template.xlsx)}';
|
||||
|
||||
protected $description = 'Generate a clean Excel template for importing suppliers';
|
||||
|
||||
/** [header label, example value, width] */
|
||||
private array $columns = [
|
||||
['Supplier ID', 'SUP-1006', 16],
|
||||
['Company Name *', 'SAFETY CHEMICAL TRADING W.L.L', 38],
|
||||
['Category', 'Chemical', 22],
|
||||
['Contact Person', 'Ali Hassan', 24],
|
||||
['Primary Email', 'ali@safetychem.com', 30],
|
||||
['Secondary Email', '', 26],
|
||||
['Phone 1', '+97333188311', 18],
|
||||
['Phone 2', '', 18],
|
||||
['WhatsApp Number', '+97334214947', 18],
|
||||
['Address', 'Manama, Bahrain', 32],
|
||||
['Website', 'https://example.com', 28],
|
||||
['Credit (Y/N)', 'Y', 14],
|
||||
['Credit Days', '30', 14],
|
||||
['Tax Number', 'TRN100000000006', 22],
|
||||
['Is Active', 'Yes', 12],
|
||||
['Remarks / Key Details','', 38],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$outputPath = $this->option('output')
|
||||
?? storage_path('app/suppliers_template.xlsx');
|
||||
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('Suppliers');
|
||||
|
||||
$lastCol = Coordinate::stringFromColumnIndex(count($this->columns));
|
||||
|
||||
// ── Header row ────────────────────────────────────────────────────────
|
||||
foreach ($this->columns as $i => [$label]) {
|
||||
$sheet->getCell(Coordinate::stringFromColumnIndex($i + 1) . '1')->setValue($label);
|
||||
}
|
||||
|
||||
$sheet->getStyle("A1:{$lastCol}1")->applyFromArray([
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF'], 'size' => 11],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '1D4ED8']],
|
||||
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'FFFFFF']]],
|
||||
]);
|
||||
$sheet->getRowDimension(1)->setRowHeight(22);
|
||||
|
||||
// ── Example rows ──────────────────────────────────────────────────────
|
||||
$examples = [
|
||||
['SUP-1006', 'SAFETY CHEMICAL TRADING W.L.L', 'Chemical', 'Ali Hassan', 'ali@safetychem.com', '', '+973 3318 8311', '', '+973 3421 4947', 'Manama, Bahrain', '', 'Y', '30', 'TRN100000000006', 'Yes', ''],
|
||||
['SUP-1007', 'The prosperity trading & Cont SPC', 'Chemical', 'Sara Al-Ali', 'sara@prosperity.bh', '', '+973 17730001', '', '', 'Riffa, Bahrain', '', '', '', 'TRN100000000007', 'Yes', ''],
|
||||
['SUP-1008', 'Al Eradah chemicals', 'Chemical', 'Mohammed Naser', 'info@aleradah.com', '', '+973 1741 3720', '', '', 'Hamad Town, Bahrain','', '', '', '', 'Yes', ''],
|
||||
];
|
||||
|
||||
foreach ($examples as $rowIdx => $values) {
|
||||
$excelRow = $rowIdx + 2;
|
||||
foreach ($values as $colIdx => $value) {
|
||||
$sheet->getCell(Coordinate::stringFromColumnIndex($colIdx + 1) . $excelRow)->setValue($value);
|
||||
}
|
||||
}
|
||||
|
||||
$lastDataRow = count($examples) + 1;
|
||||
$sheet->getStyle("A2:{$lastCol}{$lastDataRow}")->applyFromArray([
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'EFF6FF']],
|
||||
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'BFDBFE']]],
|
||||
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
|
||||
]);
|
||||
$sheet->getStyle("B2:B{$lastDataRow}")->applyFromArray([
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'DBEAFE']],
|
||||
]);
|
||||
for ($r = 2; $r <= $lastDataRow; $r++) {
|
||||
$sheet->getRowDimension($r)->setRowHeight(18);
|
||||
}
|
||||
|
||||
// ── Notes row ─────────────────────────────────────────────────────────
|
||||
$notesRow = $lastDataRow + 2;
|
||||
$sheet->getCell("A{$notesRow}")->setValue(
|
||||
'* Required. Delete example rows before importing. Duplicate Company Names are skipped. ' .
|
||||
'Supplier ID is optional — leave blank and one will be auto-assigned. ' .
|
||||
'Category, Secondary Email, Phone 2, WhatsApp, Website, Credit, Remarks are informational only.'
|
||||
);
|
||||
$sheet->mergeCells("A{$notesRow}:{$lastCol}{$notesRow}");
|
||||
$sheet->getStyle("A{$notesRow}")->applyFromArray([
|
||||
'font' => ['italic' => true, 'color' => ['rgb' => '6B7280'], 'size' => 9],
|
||||
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => 'FEF9C3']],
|
||||
]);
|
||||
$sheet->getRowDimension($notesRow)->setRowHeight(30);
|
||||
$sheet->getStyle("A{$notesRow}")->getAlignment()->setWrapText(true);
|
||||
|
||||
// ── Column widths ─────────────────────────────────────────────────────
|
||||
foreach ($this->columns as $i => [, , $width]) {
|
||||
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($i + 1))->setWidth($width);
|
||||
}
|
||||
|
||||
$dir = dirname($outputPath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
(new Xlsx($spreadsheet))->save($outputPath);
|
||||
|
||||
$this->info("Template saved to: {$outputPath}");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
220
app/Console/Commands/ImportSuppliers.php
Normal file
220
app/Console/Commands/ImportSuppliers.php
Normal file
@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Console\Command;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
class ImportSuppliers extends Command
|
||||
{
|
||||
protected $signature = 'suppliers:import
|
||||
{file : Full path to the Excel file (.xlsx or .xls)}
|
||||
{--dry-run : Preview what would be imported without saving}';
|
||||
|
||||
protected $description = 'Import suppliers from an Excel file. Supports both the MRF comparison format and the clean suppliers template. Duplicate names are skipped automatically.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$filePath = $this->argument('file');
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
$this->error("File not found: {$filePath}");
|
||||
$this->line("Tip: Use an absolute path, e.g. php artisan suppliers:import \"C:\\path\\to\\file.xlsx\"");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Reading: " . basename($filePath));
|
||||
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Could not open file: " . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$format = $this->detectFormat($spreadsheet);
|
||||
$this->line("Detected format: <comment>{$format}</comment>");
|
||||
|
||||
$suppliers = $format === 'mrf'
|
||||
? $this->extractFromMrf($spreadsheet)
|
||||
: $this->extractFromTemplate($spreadsheet);
|
||||
|
||||
if (empty($suppliers)) {
|
||||
$this->warn("No suppliers found in file.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line(str_pad('', 60, '-'));
|
||||
$this->line(sprintf(" %-30s %-10s %s", 'Name', 'Status', 'Notes'));
|
||||
$this->line(str_pad('', 60, '-'));
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($suppliers as $data) {
|
||||
$name = trim($data['name'] ?? '');
|
||||
|
||||
if (empty($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$exists = Supplier::whereRaw('LOWER(name) = ?', [strtolower($name)])->exists();
|
||||
|
||||
if ($exists) {
|
||||
$this->line(sprintf(" %-30s <fg=yellow>SKIP</> already in database", substr($name, 0, 30)));
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
Supplier::create([
|
||||
'name' => $name,
|
||||
'contact_person' => $data['contact_person'] ?? null ?: null,
|
||||
'email' => $data['email'] ?? null ?: null,
|
||||
'phone' => $data['phone'] ?? null ?: null,
|
||||
'address' => $data['address'] ?? null ?: null,
|
||||
'tax_number' => $data['tax_number'] ?? null ?: null,
|
||||
'is_active' => $this->parseBoolean($data['is_active'] ?? 'yes'),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->line(sprintf(" %-30s <fg=green>%s</> %s",
|
||||
substr($name, 0, 30),
|
||||
$dryRun ? 'PREVIEW' : 'IMPORTED',
|
||||
isset($data['email']) && $data['email'] ? $data['email'] : ''
|
||||
));
|
||||
$imported++;
|
||||
}
|
||||
|
||||
$this->line(str_pad('', 60, '-'));
|
||||
$this->newLine();
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info("DRY RUN complete — nothing was saved.");
|
||||
$this->line("Would import: {$imported} | Would skip: {$skipped}");
|
||||
} else {
|
||||
$this->info("Import complete!");
|
||||
$this->line("Imported: <fg=green>{$imported}</> | Skipped (duplicates): <fg=yellow>{$skipped}</>");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the file is an MRF comparison sheet or a clean supplier template.
|
||||
* MRF sheets have "S.No" in cell A4.
|
||||
*/
|
||||
private function detectFormat(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): string
|
||||
{
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$a4 = strtolower(trim((string) $sheet->getCell('A4')->getValue()));
|
||||
|
||||
return $a4 === 's.no' ? 'mrf' : 'template';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract supplier names from a Material Purchase Request comparison sheet.
|
||||
* Suppliers appear as column headers in row 4, starting from column G.
|
||||
*/
|
||||
private function extractFromMrf(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
|
||||
{
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$headerRow = 4;
|
||||
$startCol = 7; // Column G
|
||||
$stopWords = ['comments if any', 'lowest price', 'avg price', 'recommendation'];
|
||||
$suppliers = [];
|
||||
|
||||
for ($col = $startCol; $col <= 50; $col++) {
|
||||
$cellCoord = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($col) . $headerRow;
|
||||
$value = trim((string) $sheet->getCell($cellCoord)->getValue());
|
||||
|
||||
if (empty($value)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (in_array(strtolower($value), $stopWords)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$suppliers[] = ['name' => $value];
|
||||
}
|
||||
|
||||
return $suppliers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract supplier rows from the clean import template.
|
||||
* Row 1 must be headers matching: name, contact_person, email, phone, address, tax_number, is_active
|
||||
*/
|
||||
private function extractFromTemplate(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
|
||||
{
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$rows = $sheet->toArray(null, true, true, false);
|
||||
$suppliers = [];
|
||||
|
||||
if (empty($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Normalise header row: strip asterisks/underscores, lowercase
|
||||
$headers = array_map(
|
||||
fn($h) => strtolower(trim(str_replace(['*', '_'], [' ', ' '], (string) $h))),
|
||||
$rows[0]
|
||||
);
|
||||
|
||||
// Aliases: normalised header text → internal field name (supports both old and new formats)
|
||||
$aliases = [
|
||||
'name' => 'name',
|
||||
'company name' => 'name',
|
||||
'contact person' => 'contact_person',
|
||||
'email' => 'email',
|
||||
'primary email' => 'email',
|
||||
'phone' => 'phone',
|
||||
'phone 1' => 'phone',
|
||||
'address' => 'address',
|
||||
'tax number' => 'tax_number',
|
||||
'is active' => 'is_active',
|
||||
];
|
||||
|
||||
$map = [];
|
||||
foreach ($headers as $idx => $header) {
|
||||
if (isset($aliases[$header]) && !isset($map[$aliases[$header]])) {
|
||||
$map[$aliases[$header]] = $idx;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($map['name'])) {
|
||||
$this->error('Template is missing a recognised name column ("name", "Company Name") in row 1.');
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach (array_slice($rows, 1) as $row) {
|
||||
$name = trim((string) ($row[$map['name']] ?? ''));
|
||||
|
||||
// Skip empty rows and the notes/instruction row
|
||||
if (empty($name) || str_starts_with($name, '*')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = ['name' => $name];
|
||||
|
||||
foreach (['contact_person', 'email', 'phone', 'address', 'tax_number', 'is_active'] as $field) {
|
||||
if (isset($map[$field])) {
|
||||
$entry[$field] = trim((string) ($row[$map[$field]] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
$suppliers[] = $entry;
|
||||
}
|
||||
|
||||
return $suppliers;
|
||||
}
|
||||
|
||||
private function parseBoolean(string $value): bool
|
||||
{
|
||||
return in_array(strtolower(trim($value)), ['yes', 'true', '1', 'active', 'y']);
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
47
app/Http/Controllers/Auth/AuthenticatedSessionController.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the login view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
$request->authenticate();
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an authenticated session.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
40
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
40
app/Http/Controllers/Auth/ConfirmablePasswordController.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ConfirmablePasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the confirm password view.
|
||||
*/
|
||||
public function show(): View
|
||||
{
|
||||
return view('auth.confirm-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $request->user()->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('auth.password_confirmed_at', time());
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailVerificationNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Send a new email verification notification.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
|
||||
return back()->with('status', 'verification-link-sent');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmailVerificationPromptController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the email verification prompt.
|
||||
*/
|
||||
public function __invoke(Request $request): RedirectResponse|View
|
||||
{
|
||||
return $request->user()->hasVerifiedEmail()
|
||||
? redirect()->intended(route('dashboard', absolute: false))
|
||||
: view('auth.verify-email');
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
63
app/Http/Controllers/Auth/NewPasswordController.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset view.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
return view('auth.reset-password', ['request' => $request]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'token' => ['required'],
|
||||
'email' => ['required', 'email'],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user) use ($request) {
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $status == Password::PASSWORD_RESET
|
||||
? redirect()->route('login')->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
29
app/Http/Controllers/Auth/PasswordController.php
Normal file
29
app/Http/Controllers/Auth/PasswordController.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class PasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validateWithBag('updatePassword', [
|
||||
'current_password' => ['required', 'current_password'],
|
||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return back()->with('status', 'password-updated');
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
45
app/Http/Controllers/Auth/PasswordResetLinkController.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class PasswordResetLinkController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the password reset link request view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$status = Password::sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
return $status == Password::RESET_LINK_SENT
|
||||
? back()->with('status', __($status))
|
||||
: back()->withInput($request->only('email'))
|
||||
->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
51
app/Http/Controllers/Auth/RegisteredUserController.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegisteredUserController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the registration view.
|
||||
*/
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
]);
|
||||
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
|
||||
return redirect(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
27
app/Http/Controllers/Auth/VerifyEmailController.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class VerifyEmailController extends Controller
|
||||
{
|
||||
/**
|
||||
* Mark the authenticated user's email address as verified.
|
||||
*/
|
||||
public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||
{
|
||||
if ($request->user()->hasVerifiedEmail()) {
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
if ($request->user()->markEmailAsVerified()) {
|
||||
event(new Verified($request->user()));
|
||||
}
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
39
app/Http/Controllers/DashboardController.php
Normal file
39
app/Http/Controllers/DashboardController.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Item;
|
||||
use App\Models\ProductionOrder;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\SalesInvoice;
|
||||
use App\Models\StockLevel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$totalSales = SalesInvoice::sum('total_amount');
|
||||
|
||||
$inventoryValue = StockLevel::join('items', 'items.id', '=', 'stock_levels.item_id')
|
||||
->select(DB::raw('SUM(stock_levels.quantity * items.cost_price) as value'))
|
||||
->value('value') ?? 0;
|
||||
|
||||
$productionInProgress = ProductionOrder::where('status', 'in_progress')->count();
|
||||
|
||||
$purchasePending = PurchaseRequest::where('stage', '!=', 'complete')->count();
|
||||
|
||||
$outstandingReceivables = SalesInvoice::whereIn('status', ['unpaid', 'partial'])
|
||||
->sum(DB::raw('total_amount - paid_amount'));
|
||||
|
||||
return view('dashboard', compact(
|
||||
'totalSales',
|
||||
'inventoryValue',
|
||||
'productionInProgress',
|
||||
'purchasePending',
|
||||
'outstandingReceivables'
|
||||
));
|
||||
}
|
||||
}
|
||||
123
app/Http/Controllers/Inventory/ItemController.php
Normal file
123
app/Http/Controllers/Inventory/ItemController.php
Normal file
@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Inventory;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Services\ItemImportService;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class ItemController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$items = Item::paginate(20);
|
||||
|
||||
return view('inventory.items.index', compact('items'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('inventory.items.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|string|max:100',
|
||||
'unit_of_measure' => 'required|string|max:50',
|
||||
'cost_price' => 'nullable|numeric|min:0',
|
||||
'selling_price' => 'nullable|numeric|min:0',
|
||||
'minimum_stock_level' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
$data = $request->all();
|
||||
$data['item_code'] = 'ITEM-' . str_pad(Item::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
Item::create($data);
|
||||
|
||||
return redirect()->route('inventory.items.index')->with('success', 'Item created successfully.');
|
||||
}
|
||||
|
||||
public function show(Item $item)
|
||||
{
|
||||
return view('inventory.items.show', compact('item'));
|
||||
}
|
||||
|
||||
public function edit(Item $item)
|
||||
{
|
||||
return view('inventory.items.edit', compact('item'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Item $item)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|string|max:100',
|
||||
'unit_of_measure' => 'required|string|max:50',
|
||||
'cost_price' => 'nullable|numeric|min:0',
|
||||
'selling_price' => 'nullable|numeric|min:0',
|
||||
'minimum_stock_level' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
$item->update($request->all());
|
||||
|
||||
return redirect()->route('inventory.items.index')->with('success', 'Item updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Item $item)
|
||||
{
|
||||
$item->delete();
|
||||
|
||||
return redirect()->route('inventory.items.index')->with('success', 'Item deleted successfully.');
|
||||
}
|
||||
|
||||
public function import(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls|max:10240',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = app(ItemImportService::class)->import(
|
||||
$request->file('file')->getPathname()
|
||||
);
|
||||
|
||||
$msg = "Import complete: {$result['imported']} item(s) added";
|
||||
if ($result['skipped'] > 0) {
|
||||
$msg .= ", {$result['skipped']} skipped (already exist).";
|
||||
} else {
|
||||
$msg .= '.';
|
||||
}
|
||||
|
||||
return redirect()->route('inventory.items.index')->with('success', $msg);
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->route('inventory.items.index')
|
||||
->with('error', 'Import failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadTemplate()
|
||||
{
|
||||
$path = storage_path('app/items_template.xlsx');
|
||||
|
||||
if (!file_exists($path)) {
|
||||
Artisan::call('items:template');
|
||||
}
|
||||
|
||||
return response()->download($path, 'items_import_template.xlsx');
|
||||
}
|
||||
|
||||
public function exportPdf()
|
||||
{
|
||||
$items = Item::orderBy('category')->orderBy('item_name')->get();
|
||||
|
||||
$pdf = Pdf::loadView('inventory.items.pdf', compact('items'))
|
||||
->setPaper('a4', 'landscape');
|
||||
|
||||
return $pdf->download('items_' . now()->format('Y-m-d') . '.pdf');
|
||||
}
|
||||
}
|
||||
93
app/Http/Controllers/Inventory/StockMovementController.php
Normal file
93
app/Http/Controllers/Inventory/StockMovementController.php
Normal file
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Inventory;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Models\StockLevel;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class StockMovementController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$movements = StockMovement::with(['item', 'warehouse'])->latest()->paginate(20);
|
||||
|
||||
return view('inventory.movements.index', compact('movements'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$items = Item::all();
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('inventory.movements.create', compact('items', 'warehouses'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'item_id' => 'required|exists:items,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'type' => 'required|in:in,out,adjustment',
|
||||
'quantity' => 'required|numeric|min:0.01',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$stockLevel = StockLevel::firstOrCreate(
|
||||
['item_id' => $request->item_id, 'warehouse_id' => $request->warehouse_id],
|
||||
['quantity' => 0]
|
||||
);
|
||||
|
||||
if ($request->type === 'in' || $request->type === 'adjustment') {
|
||||
$stockLevel->increment('quantity', $request->quantity);
|
||||
} else {
|
||||
// Decrement but do not go below zero
|
||||
$decrement = min($request->quantity, $stockLevel->quantity);
|
||||
$stockLevel->decrement('quantity', $decrement);
|
||||
}
|
||||
|
||||
StockMovement::create([
|
||||
'item_id' => $request->item_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'type' => $request->type,
|
||||
'quantity' => $request->quantity,
|
||||
'notes' => $request->notes,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return redirect()->route('inventory.movements.index')->with('success', 'Stock movement recorded successfully.');
|
||||
}
|
||||
|
||||
public function show(StockMovement $stockMovement)
|
||||
{
|
||||
$stockMovement->load(['item', 'warehouse']);
|
||||
|
||||
return view('inventory.movements.show', compact('stockMovement'));
|
||||
}
|
||||
|
||||
public function edit(StockMovement $stockMovement)
|
||||
{
|
||||
return view('inventory.movements.edit', compact('stockMovement'));
|
||||
}
|
||||
|
||||
public function update(Request $request, StockMovement $stockMovement)
|
||||
{
|
||||
$request->validate([
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$stockMovement->update($request->only('notes'));
|
||||
|
||||
return redirect()->route('inventory.movements.index')->with('success', 'Stock movement updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(StockMovement $stockMovement)
|
||||
{
|
||||
$stockMovement->delete();
|
||||
|
||||
return redirect()->route('inventory.movements.index')->with('success', 'Stock movement deleted successfully.');
|
||||
}
|
||||
}
|
||||
73
app/Http/Controllers/Inventory/StockReportController.php
Normal file
73
app/Http/Controllers/Inventory/StockReportController.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Inventory;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Models\StockLevel;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StockReportController extends Controller
|
||||
{
|
||||
public function summary()
|
||||
{
|
||||
$stockLevels = StockLevel::with(['item', 'warehouse'])->get();
|
||||
|
||||
return view('inventory.reports.summary', compact('stockLevels'));
|
||||
}
|
||||
|
||||
public function movement(Request $request)
|
||||
{
|
||||
$query = StockMovement::with(['item', 'warehouse']);
|
||||
|
||||
if ($request->filled('from_date')) {
|
||||
$query->whereDate('created_at', '>=', $request->from_date);
|
||||
}
|
||||
|
||||
if ($request->filled('to_date')) {
|
||||
$query->whereDate('created_at', '<=', $request->to_date);
|
||||
}
|
||||
|
||||
if ($request->filled('item_id')) {
|
||||
$query->where('item_id', $request->item_id);
|
||||
}
|
||||
|
||||
$movements = $query->latest()->paginate(50)->withQueryString();
|
||||
$items = Item::orderBy('item_name')->get();
|
||||
|
||||
return view('inventory.reports.movement', compact('movements', 'items'));
|
||||
}
|
||||
|
||||
public function lowStock()
|
||||
{
|
||||
// Items whose total stock across all warehouses is below their minimum_stock_level
|
||||
$items = Item::withSum('stockLevels', 'quantity')
|
||||
->having(DB::raw('COALESCE(stock_levels_sum_quantity, 0)'), '<', DB::raw('minimum_stock_level'))
|
||||
->get();
|
||||
|
||||
return view('inventory.reports.low-stock', compact('items'));
|
||||
}
|
||||
|
||||
public function valuation()
|
||||
{
|
||||
$valuations = StockLevel::join('items', 'items.id', '=', 'stock_levels.item_id')
|
||||
->join('warehouses', 'warehouses.id', '=', 'stock_levels.warehouse_id')
|
||||
->select(
|
||||
'items.id as item_id',
|
||||
'items.item_name',
|
||||
'items.item_code',
|
||||
'warehouses.name as warehouse_name',
|
||||
'stock_levels.quantity',
|
||||
'items.cost_price',
|
||||
DB::raw('stock_levels.quantity * items.cost_price as valuation')
|
||||
)
|
||||
->get();
|
||||
|
||||
$totalValuation = $valuations->sum('valuation');
|
||||
|
||||
return view('inventory.reports.valuation', compact('valuations', 'totalValuation'));
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Inventory/WarehouseController.php
Normal file
63
app/Http/Controllers/Inventory/WarehouseController.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Inventory;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WarehouseController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$warehouses = Warehouse::paginate(15);
|
||||
|
||||
return view('inventory.warehouses.index', compact('warehouses'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('inventory.warehouses.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'location' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
Warehouse::create($request->all());
|
||||
|
||||
return redirect()->route('inventory.warehouses.index')->with('success', 'Warehouse created successfully.');
|
||||
}
|
||||
|
||||
public function show(Warehouse $warehouse)
|
||||
{
|
||||
return view('inventory.warehouses.show', compact('warehouse'));
|
||||
}
|
||||
|
||||
public function edit(Warehouse $warehouse)
|
||||
{
|
||||
return view('inventory.warehouses.edit', compact('warehouse'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'location' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$warehouse->update($request->all());
|
||||
|
||||
return redirect()->route('inventory.warehouses.index')->with('success', 'Warehouse updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Warehouse $warehouse)
|
||||
{
|
||||
$warehouse->delete();
|
||||
|
||||
return redirect()->route('inventory.warehouses.index')->with('success', 'Warehouse deleted successfully.');
|
||||
}
|
||||
}
|
||||
78
app/Http/Controllers/Production/BillOfMaterialController.php
Normal file
78
app/Http/Controllers/Production/BillOfMaterialController.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Production;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BillOfMaterial;
|
||||
use App\Models\Item;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BillOfMaterialController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$boms = BillOfMaterial::with(['product', 'rawMaterial'])
|
||||
->get()
|
||||
->groupBy('product_id');
|
||||
|
||||
return view('production.bom.index', compact('boms'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$finishedGoods = Item::where('category', 'finished_good')->get();
|
||||
$rawMaterials = Item::where('category', 'raw_material')->get();
|
||||
|
||||
return view('production.bom.create', compact('finishedGoods', 'rawMaterials'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'product_id' => 'required|exists:items,id',
|
||||
'raw_material_id' => 'required|exists:items,id',
|
||||
'quantity_required' => 'required|numeric|min:0.01',
|
||||
'unit_of_measure' => 'required|string|max:50',
|
||||
]);
|
||||
|
||||
BillOfMaterial::create($request->all());
|
||||
|
||||
return redirect()->route('production.bom.index')->with('success', 'BOM entry created successfully.');
|
||||
}
|
||||
|
||||
public function show(BillOfMaterial $billOfMaterial)
|
||||
{
|
||||
$billOfMaterial->load(['product', 'rawMaterial']);
|
||||
|
||||
return view('production.bom.show', compact('billOfMaterial'));
|
||||
}
|
||||
|
||||
public function edit(BillOfMaterial $billOfMaterial)
|
||||
{
|
||||
$finishedGoods = Item::where('category', 'finished_good')->get();
|
||||
$rawMaterials = Item::where('category', 'raw_material')->get();
|
||||
|
||||
return view('production.bom.edit', compact('billOfMaterial', 'finishedGoods', 'rawMaterials'));
|
||||
}
|
||||
|
||||
public function update(Request $request, BillOfMaterial $billOfMaterial)
|
||||
{
|
||||
$request->validate([
|
||||
'product_id' => 'required|exists:items,id',
|
||||
'raw_material_id' => 'required|exists:items,id',
|
||||
'quantity_required' => 'required|numeric|min:0.01',
|
||||
'unit_of_measure' => 'required|string|max:50',
|
||||
]);
|
||||
|
||||
$billOfMaterial->update($request->all());
|
||||
|
||||
return redirect()->route('production.bom.index')->with('success', 'BOM entry updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(BillOfMaterial $billOfMaterial)
|
||||
{
|
||||
$billOfMaterial->delete();
|
||||
|
||||
return redirect()->route('production.bom.index')->with('success', 'BOM entry deleted successfully.');
|
||||
}
|
||||
}
|
||||
118
app/Http/Controllers/Production/MaterialIssueController.php
Normal file
118
app/Http/Controllers/Production/MaterialIssueController.php
Normal file
@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Production;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Models\MaterialIssue;
|
||||
use App\Models\ProductionOrder;
|
||||
use App\Models\StockLevel;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MaterialIssueController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$issues = MaterialIssue::with(['productionOrder', 'item', 'warehouse'])->paginate(15);
|
||||
|
||||
return view('production.material-issues.index', compact('issues'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$productionOrders = ProductionOrder::whereIn('status', ['planned', 'in_progress'])->get();
|
||||
$items = Item::where('category', 'raw_material')->get();
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('production.material-issues.create', compact('productionOrders', 'items', 'warehouses'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'production_order_id' => 'required|exists:production_orders,id',
|
||||
'item_id' => 'required|exists:items,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'quantity' => 'required|numeric|min:0.01',
|
||||
'issue_date' => 'required|date',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($request) {
|
||||
$issueNumber = 'MI-' . str_pad(MaterialIssue::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$stockLevel = StockLevel::firstOrCreate(
|
||||
['item_id' => $request->item_id, 'warehouse_id' => $request->warehouse_id],
|
||||
['quantity' => 0]
|
||||
);
|
||||
|
||||
// Decrement stock, floor at zero
|
||||
$decrement = min($request->quantity, $stockLevel->quantity);
|
||||
$stockLevel->decrement('quantity', $decrement);
|
||||
|
||||
StockMovement::create([
|
||||
'item_id' => $request->item_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'type' => 'out',
|
||||
'quantity' => $request->quantity,
|
||||
'reference_type' => 'MaterialIssue',
|
||||
'reference_id' => null, // will be updated after create
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$issue = MaterialIssue::create([
|
||||
'issue_number' => $issueNumber,
|
||||
'production_order_id' => $request->production_order_id,
|
||||
'item_id' => $request->item_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'quantity' => $request->quantity,
|
||||
'issue_date' => $request->issue_date,
|
||||
'issued_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Backfill reference_id on the movement just created
|
||||
StockMovement::where('item_id', $request->item_id)
|
||||
->where('warehouse_id', $request->warehouse_id)
|
||||
->where('type', 'out')
|
||||
->where('reference_type', 'MaterialIssue')
|
||||
->whereNull('reference_id')
|
||||
->latest('id')
|
||||
->first()
|
||||
?->update(['reference_id' => $issue->id]);
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', 'Material issued successfully.');
|
||||
}
|
||||
|
||||
public function show(MaterialIssue $materialIssue)
|
||||
{
|
||||
$materialIssue->load(['productionOrder', 'item', 'warehouse']);
|
||||
|
||||
return view('production.material-issues.show', compact('materialIssue'));
|
||||
}
|
||||
|
||||
public function edit(MaterialIssue $materialIssue)
|
||||
{
|
||||
return view('production.material-issues.edit', compact('materialIssue'));
|
||||
}
|
||||
|
||||
public function update(Request $request, MaterialIssue $materialIssue)
|
||||
{
|
||||
$request->validate([
|
||||
'issue_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$materialIssue->update($request->only('issue_date', 'notes'));
|
||||
|
||||
return redirect()->route('production.material-issues.index')->with('success', 'Material issue updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(MaterialIssue $materialIssue)
|
||||
{
|
||||
$materialIssue->delete();
|
||||
|
||||
return redirect()->route('production.material-issues.index')->with('success', 'Material issue deleted successfully.');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Production;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Models\ProductionCost;
|
||||
use App\Models\ProductionOrder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ProductionOrderController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$orders = ProductionOrder::with('product')->paginate(15);
|
||||
|
||||
return view('production.orders.index', compact('orders'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
// Only finished_good type items can be produced
|
||||
$items = Item::where('category', 'finished_good')->get();
|
||||
|
||||
return view('production.orders.create', compact('items'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'product_id' => 'required|exists:items,id',
|
||||
'quantity_to_produce' => 'required|numeric|min:1',
|
||||
'production_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$data = $request->all();
|
||||
$data['order_number'] = 'PRO-' . str_pad(ProductionOrder::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
$data['created_by'] = auth()->id();
|
||||
$data['status'] = 'planned';
|
||||
|
||||
$order = ProductionOrder::create($data);
|
||||
|
||||
return redirect()->route('production.orders.show', $order)->with('success', 'Production order created successfully.');
|
||||
}
|
||||
|
||||
public function show(ProductionOrder $productionOrder)
|
||||
{
|
||||
$productionOrder->load(['product', 'materialIssues.item', 'outputs.item', 'cost']);
|
||||
|
||||
return view('production.orders.show', compact('productionOrder'));
|
||||
}
|
||||
|
||||
public function edit(ProductionOrder $productionOrder)
|
||||
{
|
||||
$items = Item::where('category', 'finished_good')->get();
|
||||
|
||||
return view('production.orders.edit', compact('productionOrder', 'items'));
|
||||
}
|
||||
|
||||
public function update(Request $request, ProductionOrder $productionOrder)
|
||||
{
|
||||
$request->validate([
|
||||
'production_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$productionOrder->update($request->only('production_date', 'notes'));
|
||||
|
||||
return redirect()->route('production.orders.show', $productionOrder)->with('success', 'Production order updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(ProductionOrder $productionOrder)
|
||||
{
|
||||
$productionOrder->delete();
|
||||
|
||||
return redirect()->route('production.orders.index')->with('success', 'Production order deleted successfully.');
|
||||
}
|
||||
|
||||
public function start(ProductionOrder $productionOrder)
|
||||
{
|
||||
$productionOrder->update(['status' => 'in_progress']);
|
||||
|
||||
return redirect()->back()->with('success', 'Production order started.');
|
||||
}
|
||||
|
||||
public function complete(ProductionOrder $productionOrder)
|
||||
{
|
||||
$productionOrder->update([
|
||||
'status' => 'completed',
|
||||
'completion_date' => now(),
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', 'Production order marked as completed.');
|
||||
}
|
||||
}
|
||||
105
app/Http/Controllers/Production/ProductionOutputController.php
Normal file
105
app/Http/Controllers/Production/ProductionOutputController.php
Normal file
@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Production;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Models\ProductionOrder;
|
||||
use App\Models\ProductionOutput;
|
||||
use App\Models\StockLevel;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProductionOutputController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$outputs = ProductionOutput::with(['productionOrder', 'item', 'warehouse'])->paginate(15);
|
||||
|
||||
return view('production.outputs.index', compact('outputs'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$productionOrders = ProductionOrder::whereIn('status', ['in_progress'])->with('product')->get();
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('production.outputs.create', compact('productionOrders', 'warehouses'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'production_order_id' => 'required|exists:production_orders,id',
|
||||
'item_id' => 'required|exists:items,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'quantity' => 'required|numeric|min:0.01',
|
||||
'output_date' => 'required|date',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($request) {
|
||||
$stockLevel = StockLevel::firstOrCreate(
|
||||
['item_id' => $request->item_id, 'warehouse_id' => $request->warehouse_id],
|
||||
['quantity' => 0]
|
||||
);
|
||||
$stockLevel->increment('quantity', $request->quantity);
|
||||
|
||||
$output = ProductionOutput::create([
|
||||
'production_order_id' => $request->production_order_id,
|
||||
'item_id' => $request->item_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'quantity' => $request->quantity,
|
||||
'output_date' => $request->output_date,
|
||||
'recorded_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
StockMovement::create([
|
||||
'item_id' => $request->item_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'type' => 'in',
|
||||
'quantity' => $request->quantity,
|
||||
'reference_type' => 'ProductionOutput',
|
||||
'reference_id' => $output->id,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Accumulate quantity_produced on the production order
|
||||
ProductionOrder::where('id', $request->production_order_id)
|
||||
->increment('quantity_produced', $request->quantity);
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', 'Production output recorded and stock updated.');
|
||||
}
|
||||
|
||||
public function show(ProductionOutput $productionOutput)
|
||||
{
|
||||
$productionOutput->load(['productionOrder', 'item', 'warehouse']);
|
||||
|
||||
return view('production.outputs.show', compact('productionOutput'));
|
||||
}
|
||||
|
||||
public function edit(ProductionOutput $productionOutput)
|
||||
{
|
||||
return view('production.outputs.edit', compact('productionOutput'));
|
||||
}
|
||||
|
||||
public function update(Request $request, ProductionOutput $productionOutput)
|
||||
{
|
||||
$request->validate([
|
||||
'output_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$productionOutput->update($request->only('output_date', 'notes'));
|
||||
|
||||
return redirect()->route('production.outputs.index')->with('success', 'Production output updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(ProductionOutput $productionOutput)
|
||||
{
|
||||
$productionOutput->delete();
|
||||
|
||||
return redirect()->route('production.outputs.index')->with('success', 'Production output deleted successfully.');
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/ProfileController.php
Normal file
60
app/Http/Controllers/ProfileController.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ProfileUpdateRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the user's profile form.
|
||||
*/
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validateWithBag('userDeletion', [
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return Redirect::to('/');
|
||||
}
|
||||
}
|
||||
150
app/Http/Controllers/Purchase/GoodsReceiptNoteController.php
Normal file
150
app/Http/Controllers/Purchase/GoodsReceiptNoteController.php
Normal file
@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GoodsReceiptNote;
|
||||
use App\Models\GrnItem;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\StockLevel;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class GoodsReceiptNoteController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$grns = GoodsReceiptNote::with(['purchaseOrder.supplier', 'warehouse'])->paginate(15);
|
||||
|
||||
return view('purchase.grns.index', compact('grns'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$purchaseOrders = PurchaseOrder::whereIn('status', ['sent', 'partial'])->with('supplier')->get();
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('purchase.grns.create', compact('purchaseOrders', 'warehouses'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'purchase_order_id' => 'required|exists:purchase_orders,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'received_date' => 'required|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.item_id' => 'required|exists:items,id',
|
||||
'items.*.quantity' => 'required|numeric|min:1',
|
||||
]);
|
||||
|
||||
$grnNumber = 'GRN-' . str_pad(GoodsReceiptNote::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$po = PurchaseOrder::findOrFail($request->purchase_order_id);
|
||||
|
||||
$grn = GoodsReceiptNote::create([
|
||||
'grn_number' => $grnNumber,
|
||||
'purchase_order_id' => $request->purchase_order_id,
|
||||
'supplier_id' => $po->supplier_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'received_date' => $request->received_date,
|
||||
'status' => 'draft',
|
||||
'received_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
GrnItem::create([
|
||||
'goods_receipt_note_id' => $grn->id,
|
||||
'purchase_order_item_id' => $item['po_item_id'] ?? $po->items()->where('item_id', $item['item_id'])->first()?->id ?? 0,
|
||||
'item_id' => $item['item_id'],
|
||||
'quantity_received' => $item['quantity'],
|
||||
'unit_cost' => $item['unit_cost'] ?? 0,
|
||||
'type' => in_array($item['type'] ?? '', ['inventory', 'consumable']) ? $item['type'] : 'inventory',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('purchase.grns.show', $grn)->with('success', 'GRN created successfully.');
|
||||
}
|
||||
|
||||
public function show(GoodsReceiptNote $grn)
|
||||
{
|
||||
$grn->load(['purchaseOrder.supplier', 'warehouse', 'items.item']);
|
||||
|
||||
return view('purchase.grns.show', compact('grn'));
|
||||
}
|
||||
|
||||
public function edit(GoodsReceiptNote $grn)
|
||||
{
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('purchase.grns.edit', compact('grn', 'warehouses'));
|
||||
}
|
||||
|
||||
public function update(Request $request, GoodsReceiptNote $grn)
|
||||
{
|
||||
$request->validate([
|
||||
'received_date' => 'required|date',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
]);
|
||||
|
||||
$grn->update($request->only('received_date', 'warehouse_id'));
|
||||
|
||||
return redirect()->route('purchase.grns.show', $grn)->with('success', 'GRN updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(GoodsReceiptNote $grn)
|
||||
{
|
||||
$grn->delete();
|
||||
|
||||
return redirect()->route('purchase.grns.index')->with('success', 'GRN deleted successfully.');
|
||||
}
|
||||
|
||||
public function confirm(GoodsReceiptNote $grn)
|
||||
{
|
||||
if ($grn->status === 'confirmed') {
|
||||
return redirect()->back()->with('error', 'GRN is already confirmed.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($grn) {
|
||||
$grn->load('items');
|
||||
|
||||
foreach ($grn->items as $grnItem) {
|
||||
$stockLevel = StockLevel::firstOrCreate(
|
||||
['item_id' => $grnItem->item_id, 'warehouse_id' => $grn->warehouse_id],
|
||||
['quantity' => 0]
|
||||
);
|
||||
$stockLevel->increment('quantity', $grnItem->quantity_received);
|
||||
|
||||
StockMovement::create([
|
||||
'item_id' => $grnItem->item_id,
|
||||
'warehouse_id' => $grn->warehouse_id,
|
||||
'type' => 'in',
|
||||
'quantity' => $grnItem->quantity_received,
|
||||
'reference_type' => 'GoodsReceiptNote',
|
||||
'reference_id' => $grn->id,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$grn->purchaseOrder->items()
|
||||
->where('item_id', $grnItem->item_id)
|
||||
->increment('quantity_received', $grnItem->quantity_received);
|
||||
}
|
||||
|
||||
$grn->update(['status' => 'confirmed']);
|
||||
|
||||
// Check if all PO items have been fully received
|
||||
$po = $grn->purchaseOrder->fresh(['items']);
|
||||
$allFullyReceived = $po->items->every(
|
||||
fn($poItem) => $poItem->quantity_received >= $poItem->quantity
|
||||
);
|
||||
|
||||
if ($allFullyReceived) {
|
||||
$po->update(['status' => 'received']);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', 'GRN confirmed and stock updated.');
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/Purchase/PurchaseOrderController.php
Normal file
101
app/Http/Controllers/Purchase/PurchaseOrderController.php
Normal file
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Item;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\PurchaseOrderItem;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PurchaseOrderController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$orders = PurchaseOrder::with('supplier')->paginate(15);
|
||||
|
||||
return view('purchase.orders.index', compact('orders'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$suppliers = Supplier::all();
|
||||
$items = Item::all();
|
||||
$purchaseRequests = PurchaseRequest::where('status', 'approved')->get();
|
||||
|
||||
return view('purchase.orders.create', compact('suppliers', 'items', 'purchaseRequests'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_id' => 'required|exists:suppliers,id',
|
||||
'po_date' => 'required|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.item_id' => 'required|exists:items,id',
|
||||
'items.*.quantity' => 'required|numeric|min:1',
|
||||
'items.*.rate' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$poNumber = 'PO-' . str_pad(PurchaseOrder::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$totalAmount = collect($request->items)->sum(fn($item) => $item['quantity'] * $item['rate']);
|
||||
|
||||
$order = PurchaseOrder::create([
|
||||
'po_number' => $poNumber,
|
||||
'supplier_id' => $request->supplier_id,
|
||||
'po_date' => $request->po_date,
|
||||
'total_amount' => $totalAmount,
|
||||
'status' => 'draft',
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
PurchaseOrderItem::create([
|
||||
'purchase_order_id' => $order->id,
|
||||
'item_id' => $item['item_id'],
|
||||
'quantity' => $item['quantity'],
|
||||
'rate' => $item['rate'],
|
||||
'amount' => $item['quantity'] * $item['rate'],
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('purchase.orders.show', $order)->with('success', 'Purchase order created successfully.');
|
||||
}
|
||||
|
||||
public function show(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$purchaseOrder->load(['supplier', 'items.item']);
|
||||
|
||||
return view('purchase.orders.show', compact('purchaseOrder'));
|
||||
}
|
||||
|
||||
public function edit(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$suppliers = Supplier::all();
|
||||
$items = Item::all();
|
||||
|
||||
return view('purchase.orders.edit', compact('purchaseOrder', 'suppliers', 'items'));
|
||||
}
|
||||
|
||||
public function update(Request $request, PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_id' => 'required|exists:suppliers,id',
|
||||
'po_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$purchaseOrder->update($request->only('supplier_id', 'po_date', 'status'));
|
||||
|
||||
return redirect()->route('purchase.orders.show', $purchaseOrder)->with('success', 'Purchase order updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(PurchaseOrder $purchaseOrder)
|
||||
{
|
||||
$purchaseOrder->delete();
|
||||
|
||||
return redirect()->route('purchase.orders.index')->with('success', 'Purchase order deleted successfully.');
|
||||
}
|
||||
}
|
||||
50
app/Http/Controllers/Purchase/PurchasePipelineController.php
Normal file
50
app/Http/Controllers/Purchase/PurchasePipelineController.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\PurchaseStageService;
|
||||
|
||||
class PurchasePipelineController extends Controller
|
||||
{
|
||||
private function withRelations()
|
||||
{
|
||||
return PurchaseRequest::with([
|
||||
'requestedBy',
|
||||
'signature.signedBy',
|
||||
'rfqInvitations.supplier',
|
||||
'supplierQuotes',
|
||||
'awardedQuote.supplier',
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(PurchaseStageService $stages)
|
||||
{
|
||||
$active = $this->withRelations()->where('stage', '!=', 'complete')->latest()->get();
|
||||
$completed = $this->withRelations()->where('stage', 'complete')->latest()->get();
|
||||
|
||||
return view('purchase.pipeline.index', compact('active', 'completed', 'stages'));
|
||||
}
|
||||
|
||||
public function show(PurchaseRequest $purchaseRequest, PurchaseStageService $stages)
|
||||
{
|
||||
$purchaseRequest->load([
|
||||
'requestedBy',
|
||||
'items',
|
||||
'signature.signedBy',
|
||||
'rfqInvitations.supplier',
|
||||
'supplierQuotes',
|
||||
'awardedQuote.supplier',
|
||||
]);
|
||||
|
||||
$suppliers = Supplier::where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
return view('purchase.pipeline.show', [
|
||||
'pr' => $purchaseRequest,
|
||||
'stages' => $stages,
|
||||
'suppliers' => $suppliers,
|
||||
]);
|
||||
}
|
||||
}
|
||||
173
app/Http/Controllers/Purchase/PurchaseRequestController.php
Normal file
173
app/Http/Controllers/Purchase/PurchaseRequestController.php
Normal file
@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\PurchaseRequestItem;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PurchaseRequestController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$requests = PurchaseRequest::with(['requestedBy', 'items'])->latest()->paginate(15);
|
||||
|
||||
return view('purchase.requests.index', compact('requests'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('purchase.requests.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'date' => 'required|date',
|
||||
'project_name' => 'required|string|max:255',
|
||||
'department' => 'nullable|string|max:255',
|
||||
'requested_by_name' => 'required|string|max:255',
|
||||
'required_date_text' => 'nullable|string|max:100',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'remarks' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.description' => 'required|string',
|
||||
'items.*.unit' => 'nullable|string|max:50',
|
||||
'items.*.quantity_required' => 'required|numeric|min:0.01',
|
||||
'items.*.purpose_use' => 'nullable|string|max:255',
|
||||
'items.*.required_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($request) {
|
||||
$year = now()->format('y');
|
||||
$next = PurchaseRequest::max('id') + 1;
|
||||
$mprNumber = 'MPR' . $year . '-' . str_pad($next, 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$pr = PurchaseRequest::create([
|
||||
'request_number' => $mprNumber,
|
||||
'date' => $request->date,
|
||||
'project_name' => $request->project_name,
|
||||
'department' => $request->department,
|
||||
'requested_by_name' => $request->requested_by_name,
|
||||
'required_date_text' => $request->required_date_text,
|
||||
'location' => $request->location,
|
||||
'remarks' => $request->remarks,
|
||||
'status' => 'pending',
|
||||
'requested_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
if (empty(trim($item['description']))) {
|
||||
continue;
|
||||
}
|
||||
PurchaseRequestItem::create([
|
||||
'purchase_request_id' => $pr->id,
|
||||
'description' => $item['description'],
|
||||
'unit' => $item['unit'] ?? null,
|
||||
'quantity_required' => $item['quantity_required'],
|
||||
'purpose_use' => $item['purpose_use'] ?? null,
|
||||
'required_date' => $item['required_date'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('purchase.requests.index')->with('success', 'Purchase request submitted successfully.');
|
||||
}
|
||||
|
||||
public function show(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->load(['items', 'requestedBy', 'approvedBy']);
|
||||
|
||||
return view('purchase.requests.show', compact('purchaseRequest'));
|
||||
}
|
||||
|
||||
public function edit(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->load('items');
|
||||
|
||||
return view('purchase.requests.edit', compact('purchaseRequest'));
|
||||
}
|
||||
|
||||
public function update(Request $request, PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$request->validate([
|
||||
'date' => 'required|date',
|
||||
'project_name' => 'required|string|max:255',
|
||||
'department' => 'nullable|string|max:255',
|
||||
'requested_by_name' => 'required|string|max:255',
|
||||
'required_date_text' => 'nullable|string|max:100',
|
||||
'location' => 'nullable|string|max:255',
|
||||
'remarks' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.description' => 'required|string',
|
||||
'items.*.unit' => 'nullable|string|max:50',
|
||||
'items.*.quantity_required' => 'required|numeric|min:0.01',
|
||||
'items.*.purpose_use' => 'nullable|string|max:255',
|
||||
'items.*.required_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($request, $purchaseRequest) {
|
||||
$purchaseRequest->update([
|
||||
'date' => $request->date,
|
||||
'project_name' => $request->project_name,
|
||||
'department' => $request->department,
|
||||
'requested_by_name' => $request->requested_by_name,
|
||||
'required_date_text' => $request->required_date_text,
|
||||
'location' => $request->location,
|
||||
'remarks' => $request->remarks,
|
||||
]);
|
||||
|
||||
$purchaseRequest->items()->delete();
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
if (empty(trim($item['description']))) {
|
||||
continue;
|
||||
}
|
||||
PurchaseRequestItem::create([
|
||||
'purchase_request_id' => $purchaseRequest->id,
|
||||
'description' => $item['description'],
|
||||
'unit' => $item['unit'] ?? null,
|
||||
'quantity_required' => $item['quantity_required'],
|
||||
'purpose_use' => $item['purpose_use'] ?? null,
|
||||
'required_date' => $item['required_date'] ?? null,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('purchase.requests.show', $purchaseRequest)->with('success', 'Purchase request updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->delete();
|
||||
|
||||
return redirect()->route('purchase.requests.index')->with('success', 'Purchase request deleted.');
|
||||
}
|
||||
|
||||
public function approve(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->update([
|
||||
'status' => 'approved',
|
||||
'approved_by' => auth()->id(),
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', 'Purchase request approved.');
|
||||
}
|
||||
|
||||
public function reject(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->update(['status' => 'rejected']);
|
||||
|
||||
return redirect()->back()->with('success', 'Purchase request rejected.');
|
||||
}
|
||||
|
||||
public function print(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->load(['items', 'requestedBy', 'approvedBy']);
|
||||
|
||||
return view('purchase.requests.print', compact('purchaseRequest'));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\PurchaseSignature;
|
||||
use App\Services\PurchaseStageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PurchaseSignatureController extends Controller
|
||||
{
|
||||
public function show(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$purchaseRequest->load('signature.signedBy');
|
||||
return view('purchase.signature.show', ['request' => $purchaseRequest]);
|
||||
}
|
||||
|
||||
public function store(Request $request, PurchaseRequest $purchaseRequest, PurchaseStageService $stages)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'signature_image' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if ($purchaseRequest->signature) {
|
||||
return back()->with('error', 'This request has already been signed.');
|
||||
}
|
||||
|
||||
PurchaseSignature::create([
|
||||
'purchase_request_id' => $purchaseRequest->id,
|
||||
'signed_by' => auth()->id(),
|
||||
'signature_image' => $validated['signature_image'],
|
||||
'signed_at' => now(),
|
||||
'ip_address' => $request->ip(),
|
||||
]);
|
||||
|
||||
$stages->advance($purchaseRequest);
|
||||
|
||||
return redirect()->route('purchase.pipeline.index')
|
||||
->with('success', 'Signature saved. Request moved to RFQ stage.');
|
||||
}
|
||||
}
|
||||
117
app/Http/Controllers/Purchase/RfqController.php
Normal file
117
app/Http/Controllers/Purchase/RfqController.php
Normal file
@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\PurchaseStageService;
|
||||
use App\Services\RfqInvitationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RfqController extends Controller
|
||||
{
|
||||
public function show(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$suppliers = Supplier::where('is_active', true)->orderBy('name')->get();
|
||||
$invitations = $purchaseRequest->rfqInvitations()->with('supplier', 'quote')->get();
|
||||
return view('purchase.rfq.show', ['request' => $purchaseRequest, 'suppliers' => $suppliers, 'invitations' => $invitations]);
|
||||
}
|
||||
|
||||
public function selectSuppliers(Request $request, PurchaseRequest $purchaseRequest, RfqInvitationService $service, PurchaseStageService $stages)
|
||||
{
|
||||
$mode = $request->input('mode', 'global');
|
||||
|
||||
$alreadySelected = $purchaseRequest->rfqInvitations()->pluck('supplier_id')->toArray();
|
||||
$added = 0;
|
||||
|
||||
if ($mode === 'by_item') {
|
||||
// item_suppliers[item_id][] = supplier_id → invert to supplier_id → [item_ids]
|
||||
$itemSuppliers = $request->input('item_suppliers', []);
|
||||
$supplierItems = [];
|
||||
foreach ($itemSuppliers as $itemId => $supplierIds) {
|
||||
foreach ((array) $supplierIds as $supplierId) {
|
||||
$supplierItems[$supplierId][] = (int) $itemId;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($supplierItems)) {
|
||||
return redirect()->back()->with('error', 'Please assign at least one supplier to an item.');
|
||||
}
|
||||
|
||||
foreach ($supplierItems as $supplierId => $itemIds) {
|
||||
if (in_array($supplierId, $alreadySelected)) {
|
||||
continue;
|
||||
}
|
||||
$channel = $request->input('channel_' . $supplierId, 'email');
|
||||
$supplier = Supplier::findOrFail($supplierId);
|
||||
$service->select($purchaseRequest, $supplier, $channel, $itemIds);
|
||||
$added++;
|
||||
}
|
||||
} else {
|
||||
$validated = $request->validate([
|
||||
'supplier_ids' => ['required', 'array', 'min:1'],
|
||||
'supplier_ids.*' => ['required', 'exists:suppliers,id'],
|
||||
]);
|
||||
|
||||
foreach ($validated['supplier_ids'] as $supplierId) {
|
||||
if (in_array($supplierId, $alreadySelected)) {
|
||||
continue;
|
||||
}
|
||||
$channel = $request->input('channel_' . $supplierId, 'email');
|
||||
$supplier = Supplier::findOrFail($supplierId);
|
||||
$service->select($purchaseRequest, $supplier, $channel);
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
|
||||
$stages->setStage($purchaseRequest, 'rfq');
|
||||
|
||||
return redirect()->route('purchase.pipeline.show', $purchaseRequest)
|
||||
->with('success', $added . ' supplier(s) added. Now send them the quote request links.');
|
||||
}
|
||||
|
||||
public function sendAll(PurchaseRequest $purchaseRequest, RfqInvitationService $service, PurchaseStageService $stages)
|
||||
{
|
||||
$pending = $purchaseRequest->rfqInvitations()->where('status', 'pending')->with('supplier')->get();
|
||||
|
||||
if ($pending->isEmpty()) {
|
||||
return redirect()->back()->with('error', 'No unsent invitations. Select suppliers first.');
|
||||
}
|
||||
|
||||
foreach ($pending as $invitation) {
|
||||
$service->sendInvitation($invitation);
|
||||
}
|
||||
|
||||
$stages->setStage($purchaseRequest, 'quoting');
|
||||
|
||||
return redirect()->route('purchase.pipeline.show', $purchaseRequest)
|
||||
->with('success', $pending->count() . ' supplier(s) notified. Waiting for quotes.');
|
||||
}
|
||||
|
||||
public function store(Request $request, PurchaseRequest $purchaseRequest, RfqInvitationService $service, PurchaseStageService $stages)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'supplier_ids' => ['required', 'array', 'min:1'],
|
||||
'supplier_ids.*' => ['required', 'exists:suppliers,id'],
|
||||
]);
|
||||
|
||||
$alreadyInvited = $purchaseRequest->rfqInvitations()->pluck('supplier_id')->toArray();
|
||||
|
||||
$sent = 0;
|
||||
foreach ($validated['supplier_ids'] as $supplierId) {
|
||||
if (in_array($supplierId, $alreadyInvited)) {
|
||||
continue;
|
||||
}
|
||||
$channel = $request->input('channel_' . $supplierId, 'both');
|
||||
$supplier = Supplier::findOrFail($supplierId);
|
||||
$service->invite($purchaseRequest, $supplier, $channel);
|
||||
$sent++;
|
||||
}
|
||||
|
||||
$stages->setStage($purchaseRequest, 'quoting');
|
||||
|
||||
return redirect()->route('purchase.requests.rfq', $purchaseRequest)
|
||||
->with('success', $sent . ' invitation(s) sent. Waiting for supplier quotes.');
|
||||
}
|
||||
}
|
||||
100
app/Http/Controllers/Purchase/RfqPortalController.php
Normal file
100
app/Http/Controllers/Purchase/RfqPortalController.php
Normal file
@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\RfqInvitation;
|
||||
use App\Models\SupplierQuote;
|
||||
use App\Models\SupplierQuoteItem;
|
||||
use App\Services\PurchaseStageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RfqPortalController extends Controller
|
||||
{
|
||||
private function resolve(string $token): RfqInvitation
|
||||
{
|
||||
return RfqInvitation::where('token', $token)
|
||||
->with(['purchaseRequest.items', 'supplier'])
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
public function show(string $token)
|
||||
{
|
||||
$invitation = $this->resolve($token);
|
||||
|
||||
if ($invitation->isSubmitted()) {
|
||||
return view('rfq.submitted', compact('invitation'));
|
||||
}
|
||||
|
||||
if ($invitation->isExpired()) {
|
||||
return view('rfq.expired', compact('invitation'));
|
||||
}
|
||||
|
||||
if ($invitation->status === 'sent') {
|
||||
$invitation->update(['status' => 'opened', 'opened_at' => now()]);
|
||||
}
|
||||
|
||||
$purchaseRequest = $invitation->purchaseRequest;
|
||||
$items = $purchaseRequest->items;
|
||||
|
||||
return view('rfq.show', compact('invitation', 'purchaseRequest', 'items'));
|
||||
}
|
||||
|
||||
public function submit(Request $request, string $token)
|
||||
{
|
||||
$invitation = $this->resolve($token);
|
||||
|
||||
if ($invitation->isSubmitted() || $invitation->isExpired()) {
|
||||
abort(403, 'This link is no longer valid.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'lead_time_days' => ['nullable', 'integer', 'min:0'],
|
||||
'payment_terms' => ['nullable', 'string', 'max:200'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'items' => ['required', 'array'],
|
||||
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
|
||||
]);
|
||||
|
||||
$purchaseItems = $invitation->purchaseRequest->items;
|
||||
|
||||
$quote = SupplierQuote::create([
|
||||
'rfq_invitation_id' => $invitation->id,
|
||||
'purchase_request_id' => $invitation->purchase_request_id,
|
||||
'supplier_id' => $invitation->supplier_id,
|
||||
'submitted_at' => now(),
|
||||
'lead_time_days' => $validated['lead_time_days'],
|
||||
'payment_terms' => $validated['payment_terms'],
|
||||
'notes' => $validated['notes'],
|
||||
'total_amount' => 0,
|
||||
]);
|
||||
|
||||
$total = 0;
|
||||
foreach ($purchaseItems as $i => $item) {
|
||||
$unitPrice = (float)($validated['items'][$i]['unit_price'] ?? 0);
|
||||
$qty = (float)$item->quantity;
|
||||
$totalPrice = round($unitPrice * $qty, 3);
|
||||
$total += $totalPrice;
|
||||
|
||||
SupplierQuoteItem::create([
|
||||
'supplier_quote_id' => $quote->id,
|
||||
'description' => $item->description,
|
||||
'unit' => $item->unit ?? '',
|
||||
'quantity' => $qty,
|
||||
'unit_price' => $unitPrice,
|
||||
'total_price' => $totalPrice,
|
||||
]);
|
||||
}
|
||||
|
||||
$quote->update(['total_amount' => round($total, 3)]);
|
||||
$invitation->update(['status' => 'submitted']);
|
||||
|
||||
// If at least 1 quote is in, move to comparison stage
|
||||
$pr = $invitation->purchaseRequest;
|
||||
if ($pr->stage === 'quoting') {
|
||||
app(PurchaseStageService::class)->setStage($pr, 'comparison');
|
||||
}
|
||||
|
||||
return view('rfq.submitted', compact('invitation'));
|
||||
}
|
||||
}
|
||||
137
app/Http/Controllers/Purchase/SupplierController.php
Normal file
137
app/Http/Controllers/Purchase/SupplierController.php
Normal file
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\SupplierImportService;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class SupplierController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$suppliers = Supplier::orderBy('name')->get();
|
||||
|
||||
$stats = [
|
||||
'total' => $suppliers->count(),
|
||||
'active' => $suppliers->where('is_active', true)->count(),
|
||||
'inactive' => $suppliers->where('is_active', false)->count(),
|
||||
'with_email' => $suppliers->filter(fn($s) => !empty($s->email))->count(),
|
||||
'categories' => $suppliers->whereNotNull('category')->groupBy('category')->count(),
|
||||
];
|
||||
|
||||
$categories = Supplier::whereNotNull('category')
|
||||
->where('category', '!=', '')
|
||||
->distinct()
|
||||
->orderBy('category')
|
||||
->pluck('category');
|
||||
|
||||
return view('purchase.suppliers.index', compact('suppliers', 'stats', 'categories'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('purchase.suppliers.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'credit_days' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
Supplier::create(array_merge(
|
||||
$request->only([
|
||||
'supplier_code', 'name', 'category', 'contact_person',
|
||||
'email', 'secondary_email', 'phone', 'phone2', 'whatsapp',
|
||||
'address', 'website', 'tax_number', 'credit_terms', 'credit_days', 'remarks',
|
||||
]),
|
||||
['is_active' => (bool) $request->input('is_active', 1)]
|
||||
));
|
||||
|
||||
return redirect()->route('purchase.suppliers.index')->with('success', 'Supplier created successfully.');
|
||||
}
|
||||
|
||||
public function show(Supplier $supplier)
|
||||
{
|
||||
return view('purchase.suppliers.show', compact('supplier'));
|
||||
}
|
||||
|
||||
public function edit(Supplier $supplier)
|
||||
{
|
||||
return view('purchase.suppliers.edit', compact('supplier'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Supplier $supplier)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'credit_days' => 'nullable|integer|min:0',
|
||||
'is_active' => 'nullable',
|
||||
]);
|
||||
|
||||
$supplier->update(array_merge(
|
||||
$request->only([
|
||||
'supplier_code', 'name', 'category', 'contact_person',
|
||||
'email', 'secondary_email', 'phone', 'phone2', 'whatsapp',
|
||||
'address', 'website', 'tax_number', 'credit_terms', 'credit_days', 'remarks',
|
||||
]),
|
||||
['is_active' => (bool) $request->input('is_active', 0)]
|
||||
));
|
||||
|
||||
return redirect()->route('purchase.suppliers.index')->with('success', 'Supplier updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Supplier $supplier)
|
||||
{
|
||||
$supplier->delete();
|
||||
|
||||
return redirect()->route('purchase.suppliers.index')->with('success', 'Supplier deleted successfully.');
|
||||
}
|
||||
|
||||
public function import(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls|max:10240',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = app(SupplierImportService::class)->import(
|
||||
$request->file('file')->getPathname()
|
||||
);
|
||||
|
||||
$msg = "Import complete: {$result['imported']} added, {$result['updated']} updated"
|
||||
. ($result['skipped'] > 0 ? ", {$result['skipped']} skipped." : '.');
|
||||
|
||||
return redirect()->route('purchase.suppliers.index')->with('success', $msg);
|
||||
} catch (\Exception $e) {
|
||||
return redirect()->route('purchase.suppliers.index')
|
||||
->with('error', 'Import failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadTemplate()
|
||||
{
|
||||
$path = storage_path('app/suppliers_template.xlsx');
|
||||
|
||||
if (!file_exists($path)) {
|
||||
Artisan::call('suppliers:template');
|
||||
}
|
||||
|
||||
return response()->download($path, 'suppliers_import_template.xlsx');
|
||||
}
|
||||
|
||||
public function exportPdf()
|
||||
{
|
||||
$suppliers = Supplier::orderBy('name')->get();
|
||||
|
||||
$pdf = Pdf::loadView('purchase.suppliers.pdf', compact('suppliers'))
|
||||
->setPaper('a4', 'landscape');
|
||||
|
||||
return $pdf->download('suppliers_' . now()->format('Y-m-d') . '.pdf');
|
||||
}
|
||||
}
|
||||
85
app/Http/Controllers/Purchase/SupplierInvoiceController.php
Normal file
85
app/Http/Controllers/Purchase/SupplierInvoiceController.php
Normal file
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\GoodsReceiptNote;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierInvoice;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SupplierInvoiceController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$invoices = SupplierInvoice::with('supplier')->paginate(15);
|
||||
|
||||
return view('purchase.invoices.index', compact('invoices'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$suppliers = Supplier::all();
|
||||
$purchaseOrders = PurchaseOrder::where('status', 'received')->with('supplier')->get();
|
||||
$grns = GoodsReceiptNote::where('status', 'confirmed')->with('purchaseOrder.supplier')->get();
|
||||
|
||||
return view('purchase.invoices.create', compact('suppliers', 'purchaseOrders', 'grns'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_id' => 'required|exists:suppliers,id',
|
||||
'invoice_number' => 'required|string|max:255',
|
||||
'invoice_date' => 'required|date',
|
||||
'subtotal' => 'required|numeric|min:0',
|
||||
'vat_amount' => 'required|numeric|min:0',
|
||||
'total_amount' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
SupplierInvoice::create(array_merge($request->all(), [
|
||||
'status' => 'unpaid',
|
||||
'paid_amount' => 0,
|
||||
]));
|
||||
|
||||
return redirect()->route('purchase.invoices.index')->with('success', 'Supplier invoice created successfully.');
|
||||
}
|
||||
|
||||
public function show(SupplierInvoice $supplierInvoice)
|
||||
{
|
||||
$supplierInvoice->load(['supplier', 'purchaseOrder']);
|
||||
|
||||
return view('purchase.invoices.show', compact('supplierInvoice'));
|
||||
}
|
||||
|
||||
public function edit(SupplierInvoice $supplierInvoice)
|
||||
{
|
||||
$suppliers = Supplier::all();
|
||||
|
||||
return view('purchase.invoices.edit', compact('supplierInvoice', 'suppliers'));
|
||||
}
|
||||
|
||||
public function update(Request $request, SupplierInvoice $supplierInvoice)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_id' => 'required|exists:suppliers,id',
|
||||
'invoice_number' => 'required|string|max:255',
|
||||
'invoice_date' => 'required|date',
|
||||
'subtotal' => 'required|numeric|min:0',
|
||||
'vat_amount' => 'required|numeric|min:0',
|
||||
'total_amount' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$supplierInvoice->update($request->all());
|
||||
|
||||
return redirect()->route('purchase.invoices.index')->with('success', 'Supplier invoice updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(SupplierInvoice $supplierInvoice)
|
||||
{
|
||||
$supplierInvoice->delete();
|
||||
|
||||
return redirect()->route('purchase.invoices.index')->with('success', 'Supplier invoice deleted successfully.');
|
||||
}
|
||||
}
|
||||
85
app/Http/Controllers/Purchase/SupplierPaymentController.php
Normal file
85
app/Http/Controllers/Purchase/SupplierPaymentController.php
Normal file
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SupplierInvoice;
|
||||
use App\Models\SupplierPayment;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SupplierPaymentController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$payments = SupplierPayment::with('supplierInvoice.supplier')->paginate(15);
|
||||
|
||||
return view('purchase.payments.index', compact('payments'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$invoices = SupplierInvoice::whereIn('status', ['unpaid', 'partial'])->with('supplier')->get();
|
||||
|
||||
return view('purchase.payments.create', compact('invoices'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'supplier_invoice_id' => 'required|exists:supplier_invoices,id',
|
||||
'payment_date' => 'required|date',
|
||||
'amount' => 'required|numeric|min:0.01',
|
||||
'payment_method' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$payment = SupplierPayment::create(array_merge($request->all(), [
|
||||
'created_by' => auth()->id(),
|
||||
]));
|
||||
|
||||
// Refresh invoice and recompute paid status
|
||||
$invoice = SupplierInvoice::find($request->supplier_invoice_id);
|
||||
$newPaidAmount = $invoice->paid_amount + $request->amount;
|
||||
$status = $newPaidAmount >= $invoice->total_amount ? 'paid' : 'partial';
|
||||
|
||||
$invoice->update([
|
||||
'paid_amount' => $newPaidAmount,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
return redirect()->route('purchase.payments.index')->with('success', 'Payment recorded successfully.');
|
||||
}
|
||||
|
||||
public function show(SupplierPayment $supplierPayment)
|
||||
{
|
||||
$supplierPayment->load('supplierInvoice.supplier');
|
||||
|
||||
return view('purchase.payments.show', compact('supplierPayment'));
|
||||
}
|
||||
|
||||
public function edit(SupplierPayment $supplierPayment)
|
||||
{
|
||||
$invoices = SupplierInvoice::whereIn('status', ['unpaid', 'partial'])->with('supplier')->get();
|
||||
|
||||
return view('purchase.payments.edit', compact('supplierPayment', 'invoices'));
|
||||
}
|
||||
|
||||
public function update(Request $request, SupplierPayment $supplierPayment)
|
||||
{
|
||||
$request->validate([
|
||||
'payment_date' => 'required|date',
|
||||
'amount' => 'required|numeric|min:0.01',
|
||||
'payment_method' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$supplierPayment->update($request->all());
|
||||
|
||||
return redirect()->route('purchase.payments.index')->with('success', 'Payment updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(SupplierPayment $supplierPayment)
|
||||
{
|
||||
$supplierPayment->delete();
|
||||
|
||||
return redirect()->route('purchase.payments.index')->with('success', 'Payment deleted successfully.');
|
||||
}
|
||||
}
|
||||
48
app/Http/Controllers/Purchase/SupplierQuoteController.php
Normal file
48
app/Http/Controllers/Purchase/SupplierQuoteController.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Purchase;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\SupplierQuote;
|
||||
use App\Services\PurchaseStageService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SupplierQuoteController extends Controller
|
||||
{
|
||||
public function index(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$quotes = $purchaseRequest->supplierQuotes()->with('supplier', 'items')->get();
|
||||
return view('purchase.quotes.index', ['request' => $purchaseRequest, 'quotes' => $quotes]);
|
||||
}
|
||||
|
||||
public function compare(PurchaseRequest $purchaseRequest)
|
||||
{
|
||||
$quotes = $purchaseRequest->supplierQuotes()->with('supplier', 'items')->get();
|
||||
$items = $purchaseRequest->items;
|
||||
return view('purchase.quotes.compare', ['request' => $purchaseRequest, 'quotes' => $quotes, 'items' => $items]);
|
||||
}
|
||||
|
||||
public function award(Request $request, PurchaseRequest $purchaseRequest, SupplierQuote $quote, PurchaseStageService $stages)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'award_reason' => ['required', 'string', 'min:5'],
|
||||
]);
|
||||
|
||||
if ($purchaseRequest->awardedQuote) {
|
||||
return back()->with('error', 'A quote has already been awarded for this request.');
|
||||
}
|
||||
|
||||
$quote->update([
|
||||
'is_awarded' => true,
|
||||
'award_reason' => $validated['award_reason'],
|
||||
'awarded_at' => now(),
|
||||
'awarded_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
$stages->setStage($purchaseRequest, 'lpo');
|
||||
|
||||
return redirect()->route('purchase.pipeline.index')
|
||||
->with('success', 'Quote awarded to ' . $quote->supplier->name . '. Ready to issue LPO.');
|
||||
}
|
||||
}
|
||||
67
app/Http/Controllers/Sales/CustomerController.php
Normal file
67
app/Http/Controllers/Sales/CustomerController.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CustomerController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$customers = Customer::paginate(15);
|
||||
|
||||
return view('sales.customers.index', compact('customers'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('sales.customers.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'address' => 'nullable|string',
|
||||
]);
|
||||
|
||||
Customer::create($request->all());
|
||||
|
||||
return redirect()->route('sales.customers.index')->with('success', 'Customer created successfully.');
|
||||
}
|
||||
|
||||
public function show(Customer $customer)
|
||||
{
|
||||
return view('sales.customers.show', compact('customer'));
|
||||
}
|
||||
|
||||
public function edit(Customer $customer)
|
||||
{
|
||||
return view('sales.customers.edit', compact('customer'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Customer $customer)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'address' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$customer->update($request->all());
|
||||
|
||||
return redirect()->route('sales.customers.index')->with('success', 'Customer updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(Customer $customer)
|
||||
{
|
||||
$customer->delete();
|
||||
|
||||
return redirect()->route('sales.customers.index')->with('success', 'Customer deleted successfully.');
|
||||
}
|
||||
}
|
||||
150
app/Http/Controllers/Sales/DeliveryNoteController.php
Normal file
150
app/Http/Controllers/Sales/DeliveryNoteController.php
Normal file
@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\DeliveryNote;
|
||||
use App\Models\DeliveryNoteItem;
|
||||
use App\Models\SalesOrder;
|
||||
use App\Models\SalesOrderItem;
|
||||
use App\Models\StockLevel;
|
||||
use App\Models\StockMovement;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeliveryNoteController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$notes = DeliveryNote::with(['salesOrder.customer', 'warehouse'])->paginate(15);
|
||||
|
||||
return view('sales.delivery-notes.index', compact('notes'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$salesOrders = SalesOrder::where('status', 'confirmed')->with('customer')->get();
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('sales.delivery-notes.create', compact('salesOrders', 'warehouses'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'sales_order_id' => 'required|exists:sales_orders,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'delivery_date' => 'required|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.item_id' => 'required|exists:items,id',
|
||||
'items.*.quantity' => 'required|numeric|min:1',
|
||||
]);
|
||||
|
||||
$deliveryNumber = 'DN-' . str_pad(DeliveryNote::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$note = DeliveryNote::create([
|
||||
'delivery_number' => $deliveryNumber,
|
||||
'sales_order_id' => $request->sales_order_id,
|
||||
'warehouse_id' => $request->warehouse_id,
|
||||
'delivery_date' => $request->delivery_date,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
$salesOrder = SalesOrder::with('items')->findOrFail($request->sales_order_id);
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
$soItem = $salesOrder->items->firstWhere('item_id', $item['item_id']);
|
||||
DeliveryNoteItem::create([
|
||||
'delivery_note_id' => $note->id,
|
||||
'sales_order_item_id' => $soItem?->id ?? 0,
|
||||
'item_id' => $item['item_id'],
|
||||
'quantity_delivered' => $item['quantity'],
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('sales.delivery-notes.show', $note)->with('success', 'Delivery note created successfully.');
|
||||
}
|
||||
|
||||
public function show(DeliveryNote $deliveryNote)
|
||||
{
|
||||
$deliveryNote->load(['salesOrder.customer', 'warehouse', 'items.item']);
|
||||
|
||||
return view('sales.delivery-notes.show', compact('deliveryNote'));
|
||||
}
|
||||
|
||||
public function edit(DeliveryNote $deliveryNote)
|
||||
{
|
||||
$warehouses = Warehouse::all();
|
||||
|
||||
return view('sales.delivery-notes.edit', compact('deliveryNote', 'warehouses'));
|
||||
}
|
||||
|
||||
public function update(Request $request, DeliveryNote $deliveryNote)
|
||||
{
|
||||
$request->validate([
|
||||
'delivery_date' => 'required|date',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
]);
|
||||
|
||||
$deliveryNote->update($request->only('delivery_date', 'warehouse_id'));
|
||||
|
||||
return redirect()->route('sales.delivery-notes.show', $deliveryNote)->with('success', 'Delivery note updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(DeliveryNote $deliveryNote)
|
||||
{
|
||||
$deliveryNote->delete();
|
||||
|
||||
return redirect()->route('sales.delivery-notes.index')->with('success', 'Delivery note deleted successfully.');
|
||||
}
|
||||
|
||||
public function dispatch(DeliveryNote $deliveryNote)
|
||||
{
|
||||
DB::transaction(function () use ($deliveryNote) {
|
||||
$deliveryNote->load(['items', 'salesOrder.items']);
|
||||
|
||||
foreach ($deliveryNote->items as $dnItem) {
|
||||
$stockLevel = StockLevel::firstOrCreate(
|
||||
['item_id' => $dnItem->item_id, 'warehouse_id' => $deliveryNote->warehouse_id],
|
||||
['quantity' => 0]
|
||||
);
|
||||
|
||||
$decrement = min($dnItem->quantity_delivered, $stockLevel->quantity);
|
||||
$stockLevel->decrement('quantity', $decrement);
|
||||
|
||||
StockMovement::create([
|
||||
'item_id' => $dnItem->item_id,
|
||||
'warehouse_id' => $deliveryNote->warehouse_id,
|
||||
'type' => 'out',
|
||||
'quantity' => $dnItem->quantity_delivered,
|
||||
'reference_type' => 'DeliveryNote',
|
||||
'reference_id' => $deliveryNote->id,
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
SalesOrderItem::where('sales_order_id', $deliveryNote->sales_order_id)
|
||||
->where('item_id', $dnItem->item_id)
|
||||
->increment('quantity_delivered', $dnItem->quantity_delivered);
|
||||
}
|
||||
|
||||
$deliveryNote->update([
|
||||
'status' => 'dispatched',
|
||||
'dispatched_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Check if all sales order items are fully delivered
|
||||
$salesOrder = $deliveryNote->salesOrder->fresh(['items']);
|
||||
$allFullyDelivered = $salesOrder->items->every(
|
||||
fn($soItem) => $soItem->quantity_delivered >= $soItem->quantity
|
||||
);
|
||||
|
||||
if ($allFullyDelivered) {
|
||||
$salesOrder->update(['status' => 'dispatched']);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', 'Delivery note dispatched and stock decremented.');
|
||||
}
|
||||
}
|
||||
86
app/Http/Controllers/Sales/PaymentReceiptController.php
Normal file
86
app/Http/Controllers/Sales/PaymentReceiptController.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\PaymentReceipt;
|
||||
use App\Models\SalesInvoice;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PaymentReceiptController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$receipts = PaymentReceipt::with('salesInvoice.customer')->paginate(15);
|
||||
|
||||
return view('sales.payments.index', compact('receipts'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$invoices = SalesInvoice::whereIn('status', ['unpaid', 'partial'])->with('customer')->get();
|
||||
$customers = Customer::all();
|
||||
|
||||
return view('sales.payments.create', compact('invoices', 'customers'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'sales_invoice_id' => 'required|exists:sales_invoices,id',
|
||||
'receipt_date' => 'required|date',
|
||||
'amount' => 'required|numeric|min:0.01',
|
||||
'payment_method' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$receipt = PaymentReceipt::create(array_merge($request->all(), [
|
||||
'created_by' => auth()->id(),
|
||||
]));
|
||||
|
||||
$invoice = SalesInvoice::find($request->sales_invoice_id);
|
||||
$newPaidAmount = $invoice->paid_amount + $request->amount;
|
||||
$status = $newPaidAmount >= $invoice->total_amount ? 'paid' : 'partial';
|
||||
|
||||
$invoice->update([
|
||||
'paid_amount' => $newPaidAmount,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
// Reduce the customer's outstanding balance by the amount received
|
||||
$invoice->customer()->decrement('outstanding_balance', $request->amount);
|
||||
|
||||
return redirect()->route('sales.payments.index')->with('success', 'Payment receipt recorded successfully.');
|
||||
}
|
||||
|
||||
public function show(PaymentReceipt $paymentReceipt)
|
||||
{
|
||||
$paymentReceipt->load('salesInvoice.customer');
|
||||
|
||||
return view('sales.payments.show', compact('paymentReceipt'));
|
||||
}
|
||||
|
||||
public function edit(PaymentReceipt $paymentReceipt)
|
||||
{
|
||||
return view('sales.payments.edit', compact('paymentReceipt'));
|
||||
}
|
||||
|
||||
public function update(Request $request, PaymentReceipt $paymentReceipt)
|
||||
{
|
||||
$request->validate([
|
||||
'receipt_date' => 'required|date',
|
||||
'payment_method' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$paymentReceipt->update($request->only('receipt_date', 'payment_method', 'notes'));
|
||||
|
||||
return redirect()->route('sales.payments.index')->with('success', 'Payment receipt updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(PaymentReceipt $paymentReceipt)
|
||||
{
|
||||
$paymentReceipt->delete();
|
||||
|
||||
return redirect()->route('sales.payments.index')->with('success', 'Payment receipt deleted successfully.');
|
||||
}
|
||||
}
|
||||
89
app/Http/Controllers/Sales/SalesInvoiceController.php
Normal file
89
app/Http/Controllers/Sales/SalesInvoiceController.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\SalesInvoice;
|
||||
use App\Models\SalesOrder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SalesInvoiceController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$invoices = SalesInvoice::with('customer')->paginate(15);
|
||||
|
||||
return view('sales.invoices.index', compact('invoices'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$salesOrders = SalesOrder::where('status', 'dispatched')->with('customer')->get();
|
||||
$customers = Customer::all();
|
||||
|
||||
return view('sales.invoices.create', compact('salesOrders', 'customers'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'sales_order_id' => 'required|exists:sales_orders,id',
|
||||
'customer_id' => 'required|exists:customers,id',
|
||||
'invoice_date' => 'required|date',
|
||||
'subtotal' => 'required|numeric|min:0',
|
||||
'vat_rate' => 'required|numeric|min:0',
|
||||
'vat_amount' => 'required|numeric|min:0',
|
||||
'total_amount' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$invoiceNumber = 'INV-' . str_pad(SalesInvoice::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
SalesInvoice::create(array_merge($request->all(), [
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'status' => 'unpaid',
|
||||
'paid_amount' => 0,
|
||||
'created_by' => auth()->id(),
|
||||
]));
|
||||
|
||||
SalesOrder::where('id', $request->sales_order_id)->update(['status' => 'invoiced']);
|
||||
|
||||
return redirect()->route('sales.invoices.index')->with('success', 'Invoice created successfully.');
|
||||
}
|
||||
|
||||
public function show(SalesInvoice $salesInvoice)
|
||||
{
|
||||
$salesInvoice->load(['customer', 'salesOrder', 'paymentReceipts']);
|
||||
|
||||
return view('sales.invoices.show', compact('salesInvoice'));
|
||||
}
|
||||
|
||||
public function edit(SalesInvoice $salesInvoice)
|
||||
{
|
||||
$customers = Customer::all();
|
||||
|
||||
return view('sales.invoices.edit', compact('salesInvoice', 'customers'));
|
||||
}
|
||||
|
||||
public function update(Request $request, SalesInvoice $salesInvoice)
|
||||
{
|
||||
$request->validate([
|
||||
'invoice_date' => 'required|date',
|
||||
'subtotal' => 'required|numeric|min:0',
|
||||
'vat_rate' => 'required|numeric|min:0',
|
||||
'vat_amount' => 'required|numeric|min:0',
|
||||
'total_amount' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$salesInvoice->update($request->all());
|
||||
|
||||
return redirect()->route('sales.invoices.show', $salesInvoice)->with('success', 'Invoice updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(SalesInvoice $salesInvoice)
|
||||
{
|
||||
$salesInvoice->delete();
|
||||
|
||||
return redirect()->route('sales.invoices.index')->with('success', 'Invoice deleted successfully.');
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Sales/SalesOrderController.php
Normal file
106
app/Http/Controllers/Sales/SalesOrderController.php
Normal file
@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sales;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Item;
|
||||
use App\Models\SalesOrder;
|
||||
use App\Models\SalesOrderItem;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SalesOrderController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$orders = SalesOrder::with('customer')->paginate(15);
|
||||
|
||||
return view('sales.orders.index', compact('orders'));
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$customers = Customer::all();
|
||||
$items = Item::where('category', 'finished_good')->get();
|
||||
|
||||
return view('sales.orders.create', compact('customers', 'items'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'customer_id' => 'required|exists:customers,id',
|
||||
'order_date' => 'required|date',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.item_id' => 'required|exists:items,id',
|
||||
'items.*.quantity' => 'required|numeric|min:1',
|
||||
'items.*.unit_price' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$orderNumber = 'SO-' . str_pad(SalesOrder::max('id') + 1, 5, '0', STR_PAD_LEFT);
|
||||
$totalAmount = collect($request->items)->sum(fn($item) => $item['quantity'] * $item['unit_price']);
|
||||
|
||||
$order = SalesOrder::create([
|
||||
'order_number' => $orderNumber,
|
||||
'customer_id' => $request->customer_id,
|
||||
'order_date' => $request->order_date,
|
||||
'total_amount' => $totalAmount,
|
||||
'status' => 'draft',
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
foreach ($request->items as $item) {
|
||||
SalesOrderItem::create([
|
||||
'sales_order_id' => $order->id,
|
||||
'item_id' => $item['item_id'],
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unit_price'],
|
||||
'amount' => $item['quantity'] * $item['unit_price'],
|
||||
'quantity_delivered' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('sales.orders.show', $order)->with('success', 'Sales order created successfully.');
|
||||
}
|
||||
|
||||
public function show(SalesOrder $salesOrder)
|
||||
{
|
||||
$salesOrder->load(['customer', 'items.item', 'deliveryNotes', 'invoices']);
|
||||
|
||||
return view('sales.orders.show', compact('salesOrder'));
|
||||
}
|
||||
|
||||
public function edit(SalesOrder $salesOrder)
|
||||
{
|
||||
$customers = Customer::all();
|
||||
$items = Item::where('category', 'finished_good')->get();
|
||||
|
||||
return view('sales.orders.edit', compact('salesOrder', 'customers', 'items'));
|
||||
}
|
||||
|
||||
public function update(Request $request, SalesOrder $salesOrder)
|
||||
{
|
||||
$request->validate([
|
||||
'customer_id' => 'required|exists:customers,id',
|
||||
'order_date' => 'required|date',
|
||||
]);
|
||||
|
||||
$salesOrder->update($request->only('customer_id', 'order_date', 'notes'));
|
||||
|
||||
return redirect()->route('sales.orders.show', $salesOrder)->with('success', 'Sales order updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy(SalesOrder $salesOrder)
|
||||
{
|
||||
$salesOrder->delete();
|
||||
|
||||
return redirect()->route('sales.orders.index')->with('success', 'Sales order deleted successfully.');
|
||||
}
|
||||
|
||||
public function confirm(SalesOrder $salesOrder)
|
||||
{
|
||||
$salesOrder->update(['status' => 'confirmed']);
|
||||
|
||||
return redirect()->back()->with('success', 'Sales order confirmed.');
|
||||
}
|
||||
}
|
||||
86
app/Http/Requests/Auth/LoginRequest.php
Normal file
86
app/Http/Requests/Auth/LoginRequest.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to authenticate the request's credentials.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function authenticate(): void
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
|
||||
RateLimiter::hit($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the login request is not rate limited.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event(new Lockout($this));
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiting throttle key for the request.
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/ProfileUpdateRequest.php
Normal file
31
app/Http/Requests/ProfileUpdateRequest.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'lowercase',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::unique(User::class)->ignore($this->user()->id),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Mail/RfqInvitationMail.php
Normal file
29
app/Mail/RfqInvitationMail.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\RfqInvitation;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RfqInvitationMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public RfqInvitation $invitation) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Quote Request — ' . $this->invitation->purchaseRequest->request_number,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'mail.rfq-invitation');
|
||||
}
|
||||
}
|
||||
27
app/Models/BillOfMaterial.php
Normal file
27
app/Models/BillOfMaterial.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BillOfMaterial extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'bill_of_materials';
|
||||
|
||||
protected $fillable = ['product_id', 'raw_material_id', 'quantity_required', 'unit_of_measure', 'notes'];
|
||||
|
||||
protected $casts = ['quantity_required' => 'decimal:2'];
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Item::class, 'product_id');
|
||||
}
|
||||
|
||||
public function rawMaterial()
|
||||
{
|
||||
return $this->belongsTo(Item::class, 'raw_material_id');
|
||||
}
|
||||
}
|
||||
34
app/Models/Customer.php
Normal file
34
app/Models/Customer.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Customer extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'contact_person', 'email', 'phone', 'address', 'tax_number', 'credit_limit', 'outstanding_balance', 'is_active'];
|
||||
|
||||
protected $casts = [
|
||||
'credit_limit' => 'decimal:2',
|
||||
'outstanding_balance' => 'decimal:2',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function salesOrders()
|
||||
{
|
||||
return $this->hasMany(SalesOrder::class);
|
||||
}
|
||||
|
||||
public function salesInvoices()
|
||||
{
|
||||
return $this->hasMany(SalesInvoice::class);
|
||||
}
|
||||
|
||||
public function paymentReceipts()
|
||||
{
|
||||
return $this->hasMany(PaymentReceipt::class);
|
||||
}
|
||||
}
|
||||
40
app/Models/DeliveryNote.php
Normal file
40
app/Models/DeliveryNote.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DeliveryNote extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['delivery_number', 'sales_order_id', 'customer_id', 'warehouse_id', 'delivery_date', 'status', 'notes', 'dispatched_by'];
|
||||
|
||||
protected $casts = ['delivery_date' => 'date'];
|
||||
|
||||
public function salesOrder()
|
||||
{
|
||||
return $this->belongsTo(SalesOrder::class);
|
||||
}
|
||||
|
||||
public function customer()
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function warehouse()
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(DeliveryNoteItem::class);
|
||||
}
|
||||
|
||||
public function dispatchedBy()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'dispatched_by');
|
||||
}
|
||||
}
|
||||
30
app/Models/DeliveryNoteItem.php
Normal file
30
app/Models/DeliveryNoteItem.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DeliveryNoteItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['delivery_note_id', 'sales_order_item_id', 'item_id', 'quantity_delivered'];
|
||||
|
||||
protected $casts = ['quantity_delivered' => 'decimal:2'];
|
||||
|
||||
public function deliveryNote()
|
||||
{
|
||||
return $this->belongsTo(DeliveryNote::class);
|
||||
}
|
||||
|
||||
public function salesOrderItem()
|
||||
{
|
||||
return $this->belongsTo(SalesOrderItem::class);
|
||||
}
|
||||
|
||||
public function item()
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
}
|
||||
40
app/Models/GoodsReceiptNote.php
Normal file
40
app/Models/GoodsReceiptNote.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class GoodsReceiptNote extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['grn_number', 'purchase_order_id', 'supplier_id', 'warehouse_id', 'received_date', 'status', 'notes', 'received_by'];
|
||||
|
||||
protected $casts = ['received_date' => 'date'];
|
||||
|
||||
public function purchaseOrder()
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrder::class);
|
||||
}
|
||||
|
||||
public function supplier()
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
public function warehouse()
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(GrnItem::class);
|
||||
}
|
||||
|
||||
public function receivedBy()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'received_by');
|
||||
}
|
||||
}
|
||||
33
app/Models/GrnItem.php
Normal file
33
app/Models/GrnItem.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class GrnItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['goods_receipt_note_id', 'purchase_order_item_id', 'item_id', 'quantity_received', 'unit_cost', 'type'];
|
||||
|
||||
protected $casts = [
|
||||
'quantity_received' => 'decimal:2',
|
||||
'unit_cost' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function goodsReceiptNote()
|
||||
{
|
||||
return $this->belongsTo(GoodsReceiptNote::class);
|
||||
}
|
||||
|
||||
public function purchaseOrderItem()
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrderItem::class);
|
||||
}
|
||||
|
||||
public function item()
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
}
|
||||
39
app/Models/Item.php
Normal file
39
app/Models/Item.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Item extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['item_code', 'item_name', 'category', 'unit_of_measure', 'minimum_stock_level', 'cost_price', 'description', 'is_active'];
|
||||
|
||||
protected $casts = [
|
||||
'minimum_stock_level' => 'decimal:2',
|
||||
'cost_price' => 'decimal:2',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function stockLevels()
|
||||
{
|
||||
return $this->hasMany(StockLevel::class);
|
||||
}
|
||||
|
||||
public function stockMovements()
|
||||
{
|
||||
return $this->hasMany(StockMovement::class);
|
||||
}
|
||||
|
||||
public function billOfMaterials()
|
||||
{
|
||||
return $this->hasMany(BillOfMaterial::class, 'product_id');
|
||||
}
|
||||
|
||||
public function bomComponents()
|
||||
{
|
||||
return $this->hasMany(BillOfMaterial::class, 'raw_material_id');
|
||||
}
|
||||
}
|
||||
38
app/Models/MaterialIssue.php
Normal file
38
app/Models/MaterialIssue.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class MaterialIssue extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['issue_number', 'production_order_id', 'item_id', 'warehouse_id', 'quantity', 'issue_date', 'notes', 'issued_by'];
|
||||
|
||||
protected $casts = [
|
||||
'issue_date' => 'date',
|
||||
'quantity' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function productionOrder()
|
||||
{
|
||||
return $this->belongsTo(ProductionOrder::class);
|
||||
}
|
||||
|
||||
public function item()
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
|
||||
public function warehouse()
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function issuedBy()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'issued_by');
|
||||
}
|
||||
}
|
||||
33
app/Models/PaymentReceipt.php
Normal file
33
app/Models/PaymentReceipt.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PaymentReceipt extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['sales_invoice_id', 'customer_id', 'receipt_date', 'amount', 'payment_method', 'reference_number', 'notes', 'created_by'];
|
||||
|
||||
protected $casts = [
|
||||
'receipt_date' => 'date',
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function salesInvoice()
|
||||
{
|
||||
return $this->belongsTo(SalesInvoice::class);
|
||||
}
|
||||
|
||||
public function customer()
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function createdBy()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
26
app/Models/ProductionCost.php
Normal file
26
app/Models/ProductionCost.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProductionCost extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['production_order_id', 'raw_material_cost', 'labor_cost', 'machine_cost', 'overhead_cost', 'total_cost', 'notes'];
|
||||
|
||||
protected $casts = [
|
||||
'raw_material_cost' => 'decimal:2',
|
||||
'labor_cost' => 'decimal:2',
|
||||
'machine_cost' => 'decimal:2',
|
||||
'overhead_cost' => 'decimal:2',
|
||||
'total_cost' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function productionOrder()
|
||||
{
|
||||
return $this->belongsTo(ProductionOrder::class);
|
||||
}
|
||||
}
|
||||
45
app/Models/ProductionOrder.php
Normal file
45
app/Models/ProductionOrder.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProductionOrder extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['order_number', 'product_id', 'quantity_to_produce', 'quantity_produced', 'production_date', 'completion_date', 'status', 'notes', 'created_by'];
|
||||
|
||||
protected $casts = [
|
||||
'production_date' => 'date',
|
||||
'completion_date' => 'date',
|
||||
'quantity_to_produce' => 'decimal:2',
|
||||
'quantity_produced' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Item::class, 'product_id');
|
||||
}
|
||||
|
||||
public function materialIssues()
|
||||
{
|
||||
return $this->hasMany(MaterialIssue::class);
|
||||
}
|
||||
|
||||
public function outputs()
|
||||
{
|
||||
return $this->hasMany(ProductionOutput::class);
|
||||
}
|
||||
|
||||
public function cost()
|
||||
{
|
||||
return $this->hasOne(ProductionCost::class);
|
||||
}
|
||||
|
||||
public function createdBy()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
38
app/Models/ProductionOutput.php
Normal file
38
app/Models/ProductionOutput.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ProductionOutput extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['production_order_id', 'item_id', 'warehouse_id', 'quantity', 'output_date', 'notes', 'recorded_by'];
|
||||
|
||||
protected $casts = [
|
||||
'output_date' => 'date',
|
||||
'quantity' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function productionOrder()
|
||||
{
|
||||
return $this->belongsTo(ProductionOrder::class);
|
||||
}
|
||||
|
||||
public function item()
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
|
||||
public function warehouse()
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function recordedBy()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'recorded_by');
|
||||
}
|
||||
}
|
||||
49
app/Models/PurchaseOrder.php
Normal file
49
app/Models/PurchaseOrder.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PurchaseOrder extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['po_number', 'supplier_id', 'purchase_request_id', 'po_date', 'expected_delivery_date', 'total_amount', 'status', 'notes', 'created_by'];
|
||||
|
||||
protected $casts = [
|
||||
'po_date' => 'date',
|
||||
'expected_delivery_date' => 'date',
|
||||
'total_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function supplier()
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
public function purchaseRequest()
|
||||
{
|
||||
return $this->belongsTo(PurchaseRequest::class);
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(PurchaseOrderItem::class);
|
||||
}
|
||||
|
||||
public function goodsReceiptNotes()
|
||||
{
|
||||
return $this->hasMany(GoodsReceiptNote::class);
|
||||
}
|
||||
|
||||
public function supplierInvoices()
|
||||
{
|
||||
return $this->hasMany(SupplierInvoice::class);
|
||||
}
|
||||
|
||||
public function createdBy()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
30
app/Models/PurchaseOrderItem.php
Normal file
30
app/Models/PurchaseOrderItem.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PurchaseOrderItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['purchase_order_id', 'item_id', 'quantity', 'rate', 'total_amount', 'quantity_received'];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:2',
|
||||
'rate' => 'decimal:2',
|
||||
'total_amount' => 'decimal:2',
|
||||
'quantity_received' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function purchaseOrder()
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrder::class);
|
||||
}
|
||||
|
||||
public function item()
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
}
|
||||
63
app/Models/PurchaseRequest.php
Normal file
63
app/Models/PurchaseRequest.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PurchaseRequest extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'request_number', 'date', 'project_name', 'department',
|
||||
'requested_by_name', 'required_date_text', 'location',
|
||||
'remarks', 'status', 'stage', 'verified_by_name',
|
||||
'requested_by', 'approved_by', 'approved_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'date',
|
||||
'approved_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(PurchaseRequestItem::class);
|
||||
}
|
||||
|
||||
public function requestedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'requested_by');
|
||||
}
|
||||
|
||||
public function approvedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
public function purchaseOrders()
|
||||
{
|
||||
return $this->hasMany(PurchaseOrder::class);
|
||||
}
|
||||
|
||||
public function signature()
|
||||
{
|
||||
return $this->hasOne(PurchaseSignature::class);
|
||||
}
|
||||
|
||||
public function rfqInvitations()
|
||||
{
|
||||
return $this->hasMany(RfqInvitation::class);
|
||||
}
|
||||
|
||||
public function supplierQuotes()
|
||||
{
|
||||
return $this->hasMany(SupplierQuote::class);
|
||||
}
|
||||
|
||||
public function awardedQuote()
|
||||
{
|
||||
return $this->hasOne(SupplierQuote::class)->where('is_awarded', true);
|
||||
}
|
||||
}
|
||||
26
app/Models/PurchaseRequestItem.php
Normal file
26
app/Models/PurchaseRequestItem.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PurchaseRequestItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'purchase_request_id', 'description', 'unit',
|
||||
'quantity_required', 'purpose_use', 'required_date',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'required_date' => 'date',
|
||||
'quantity_required' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function purchaseRequest()
|
||||
{
|
||||
return $this->belongsTo(PurchaseRequest::class);
|
||||
}
|
||||
}
|
||||
24
app/Models/PurchaseSignature.php
Normal file
24
app/Models/PurchaseSignature.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PurchaseSignature extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'purchase_request_id', 'signed_by', 'signature_image', 'signed_at', 'ip_address',
|
||||
];
|
||||
|
||||
protected $casts = ['signed_at' => 'datetime'];
|
||||
|
||||
public function purchaseRequest()
|
||||
{
|
||||
return $this->belongsTo(PurchaseRequest::class);
|
||||
}
|
||||
|
||||
public function signedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'signed_by');
|
||||
}
|
||||
}
|
||||
45
app/Models/RfqInvitation.php
Normal file
45
app/Models/RfqInvitation.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class RfqInvitation extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'purchase_request_id', 'supplier_id', 'token', 'channel',
|
||||
'sent_at', 'opened_at', 'expires_at', 'status', 'item_ids',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sent_at' => 'datetime',
|
||||
'opened_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'item_ids' => 'array',
|
||||
];
|
||||
|
||||
public function purchaseRequest()
|
||||
{
|
||||
return $this->belongsTo(PurchaseRequest::class);
|
||||
}
|
||||
|
||||
public function supplier()
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
public function quote()
|
||||
{
|
||||
return $this->hasOne(SupplierQuote::class);
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at && $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isSubmitted(): bool
|
||||
{
|
||||
return $this->status === 'submitted';
|
||||
}
|
||||
}
|
||||
48
app/Models/SalesInvoice.php
Normal file
48
app/Models/SalesInvoice.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SalesInvoice extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['invoice_number', 'sales_order_id', 'customer_id', 'invoice_date', 'due_date', 'subtotal', 'vat_rate', 'vat_amount', 'total_amount', 'paid_amount', 'status', 'notes', 'created_by'];
|
||||
|
||||
protected $casts = [
|
||||
'invoice_date' => 'date',
|
||||
'due_date' => 'date',
|
||||
'subtotal' => 'decimal:2',
|
||||
'vat_rate' => 'decimal:2',
|
||||
'vat_amount' => 'decimal:2',
|
||||
'total_amount' => 'decimal:2',
|
||||
'paid_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function salesOrder()
|
||||
{
|
||||
return $this->belongsTo(SalesOrder::class);
|
||||
}
|
||||
|
||||
public function customer()
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function paymentReceipts()
|
||||
{
|
||||
return $this->hasMany(PaymentReceipt::class);
|
||||
}
|
||||
|
||||
public function createdBy()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function getOutstandingAttribute()
|
||||
{
|
||||
return $this->total_amount - $this->paid_amount;
|
||||
}
|
||||
}
|
||||
44
app/Models/SalesOrder.php
Normal file
44
app/Models/SalesOrder.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SalesOrder extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['order_number', 'customer_id', 'order_date', 'delivery_date', 'total_amount', 'status', 'notes', 'created_by'];
|
||||
|
||||
protected $casts = [
|
||||
'order_date' => 'date',
|
||||
'delivery_date' => 'date',
|
||||
'total_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function customer()
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(SalesOrderItem::class);
|
||||
}
|
||||
|
||||
public function deliveryNotes()
|
||||
{
|
||||
return $this->hasMany(DeliveryNote::class);
|
||||
}
|
||||
|
||||
public function invoices()
|
||||
{
|
||||
return $this->hasMany(SalesInvoice::class);
|
||||
}
|
||||
|
||||
public function createdBy()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
30
app/Models/SalesOrderItem.php
Normal file
30
app/Models/SalesOrderItem.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SalesOrderItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['sales_order_id', 'item_id', 'quantity', 'price', 'total_amount', 'quantity_delivered'];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:2',
|
||||
'price' => 'decimal:2',
|
||||
'total_amount' => 'decimal:2',
|
||||
'quantity_delivered' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function salesOrder()
|
||||
{
|
||||
return $this->belongsTo(SalesOrder::class);
|
||||
}
|
||||
|
||||
public function item()
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
}
|
||||
25
app/Models/StockLevel.php
Normal file
25
app/Models/StockLevel.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StockLevel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['item_id', 'warehouse_id', 'quantity'];
|
||||
|
||||
protected $casts = ['quantity' => 'decimal:2'];
|
||||
|
||||
public function item()
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
|
||||
public function warehouse()
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
}
|
||||
30
app/Models/StockMovement.php
Normal file
30
app/Models/StockMovement.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class StockMovement extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['item_id', 'warehouse_id', 'type', 'quantity', 'reference_type', 'reference_id', 'notes', 'created_by'];
|
||||
|
||||
protected $casts = ['quantity' => 'decimal:2'];
|
||||
|
||||
public function item()
|
||||
{
|
||||
return $this->belongsTo(Item::class);
|
||||
}
|
||||
|
||||
public function warehouse()
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function createdBy()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
37
app/Models/Supplier.php
Normal file
37
app/Models/Supplier.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Supplier extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'supplier_code', 'name', 'category',
|
||||
'contact_person', 'email', 'secondary_email',
|
||||
'phone', 'phone2', 'whatsapp',
|
||||
'address', 'website',
|
||||
'tax_number', 'credit_terms', 'credit_days',
|
||||
'is_active', 'remarks',
|
||||
];
|
||||
|
||||
protected $casts = ['is_active' => 'boolean'];
|
||||
|
||||
public function purchaseOrders()
|
||||
{
|
||||
return $this->hasMany(PurchaseOrder::class);
|
||||
}
|
||||
|
||||
public function invoices()
|
||||
{
|
||||
return $this->hasMany(SupplierInvoice::class);
|
||||
}
|
||||
|
||||
public function payments()
|
||||
{
|
||||
return $this->hasMany(SupplierPayment::class);
|
||||
}
|
||||
}
|
||||
47
app/Models/SupplierInvoice.php
Normal file
47
app/Models/SupplierInvoice.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SupplierInvoice extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['invoice_number', 'supplier_id', 'purchase_order_id', 'goods_receipt_note_id', 'invoice_date', 'due_date', 'subtotal', 'vat_amount', 'total_amount', 'paid_amount', 'status', 'notes'];
|
||||
|
||||
protected $casts = [
|
||||
'invoice_date' => 'date',
|
||||
'due_date' => 'date',
|
||||
'subtotal' => 'decimal:2',
|
||||
'vat_amount' => 'decimal:2',
|
||||
'total_amount' => 'decimal:2',
|
||||
'paid_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function supplier()
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
public function purchaseOrder()
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrder::class);
|
||||
}
|
||||
|
||||
public function goodsReceiptNote()
|
||||
{
|
||||
return $this->belongsTo(GoodsReceiptNote::class);
|
||||
}
|
||||
|
||||
public function payments()
|
||||
{
|
||||
return $this->hasMany(SupplierPayment::class);
|
||||
}
|
||||
|
||||
public function getOutstandingAttribute()
|
||||
{
|
||||
return $this->total_amount - $this->paid_amount;
|
||||
}
|
||||
}
|
||||
33
app/Models/SupplierPayment.php
Normal file
33
app/Models/SupplierPayment.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SupplierPayment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['supplier_invoice_id', 'supplier_id', 'payment_date', 'amount', 'payment_method', 'reference_number', 'notes', 'created_by'];
|
||||
|
||||
protected $casts = [
|
||||
'payment_date' => 'date',
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function supplierInvoice()
|
||||
{
|
||||
return $this->belongsTo(SupplierInvoice::class);
|
||||
}
|
||||
|
||||
public function supplier()
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
public function createdBy()
|
||||
{
|
||||
return $this->belongsTo(\App\Models\User::class, 'created_by');
|
||||
}
|
||||
}
|
||||
45
app/Models/SupplierQuote.php
Normal file
45
app/Models/SupplierQuote.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SupplierQuote extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'rfq_invitation_id', 'purchase_request_id', 'supplier_id',
|
||||
'submitted_at', 'lead_time_days', 'payment_terms', 'notes',
|
||||
'total_amount', 'is_awarded', 'award_reason', 'awarded_at', 'awarded_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'submitted_at' => 'datetime',
|
||||
'awarded_at' => 'datetime',
|
||||
'is_awarded' => 'boolean',
|
||||
];
|
||||
|
||||
public function rfqInvitation()
|
||||
{
|
||||
return $this->belongsTo(RfqInvitation::class);
|
||||
}
|
||||
|
||||
public function purchaseRequest()
|
||||
{
|
||||
return $this->belongsTo(PurchaseRequest::class);
|
||||
}
|
||||
|
||||
public function supplier()
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(SupplierQuoteItem::class);
|
||||
}
|
||||
|
||||
public function awardedBy()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'awarded_by');
|
||||
}
|
||||
}
|
||||
17
app/Models/SupplierQuoteItem.php
Normal file
17
app/Models/SupplierQuoteItem.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class SupplierQuoteItem extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'supplier_quote_id', 'description', 'unit', 'quantity', 'unit_price', 'total_price',
|
||||
];
|
||||
|
||||
public function quote()
|
||||
{
|
||||
return $this->belongsTo(SupplierQuote::class, 'supplier_quote_id');
|
||||
}
|
||||
}
|
||||
50
app/Models/User.php
Normal file
50
app/Models/User.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable, HasRoles;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Models/Warehouse.php
Normal file
25
app/Models/Warehouse.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Warehouse extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['code', 'name', 'location', 'description', 'is_active'];
|
||||
|
||||
protected $casts = ['is_active' => 'boolean'];
|
||||
|
||||
public function stockLevels()
|
||||
{
|
||||
return $this->hasMany(StockLevel::class);
|
||||
}
|
||||
|
||||
public function stockMovements()
|
||||
{
|
||||
return $this->hasMany(StockMovement::class);
|
||||
}
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
197
app/Services/ItemImportService.php
Normal file
197
app/Services/ItemImportService.php
Normal file
@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Item;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
class ItemImportService
|
||||
{
|
||||
/** Section-header text → category value */
|
||||
private array $sectionMap = [
|
||||
'material inventory for sale' => 'finished_good',
|
||||
'chemical materials' => 'raw_material',
|
||||
'natural pigments' => 'raw_material',
|
||||
'raw materials' => 'raw_material',
|
||||
'forkoll bags' => 'raw_material',
|
||||
'others' => 'raw_material',
|
||||
];
|
||||
|
||||
/** Rows whose column-A value should be skipped entirely */
|
||||
private array $skipWords = ['sub total', 'subtotal', 'total', 'si. no', 'si no', 'sl. no', 'sl no', 's.no'];
|
||||
|
||||
/** Col-B values that indicate a header row, not data */
|
||||
private array $headerWords = ['description', 'item name', 'item_name', 'material'];
|
||||
|
||||
public function import(string $filePath): array
|
||||
{
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
$format = $this->detectFormat($spreadsheet);
|
||||
|
||||
$rows = $format === 'forkoll'
|
||||
? $this->extractFromForkoll($spreadsheet)
|
||||
: $this->extractFromTemplate($spreadsheet);
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
$codeSeq = (Item::max('id') ?? 0) + 1;
|
||||
|
||||
foreach ($rows as $data) {
|
||||
$name = trim($data['item_name'] ?? '');
|
||||
if (empty($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$exists = Item::whereRaw('LOWER(item_name) = ?', [strtolower($name)])->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$active = !str_contains(strtolower($name), 'not in use');
|
||||
$itemCode = $data['item_code'] ?? ('ITEM-' . str_pad($codeSeq, 5, '0', STR_PAD_LEFT));
|
||||
$codeSeq++;
|
||||
|
||||
Item::create([
|
||||
'item_code' => $itemCode,
|
||||
'item_name' => $name,
|
||||
'category' => $data['category'] ?? 'finished_good',
|
||||
'unit_of_measure' => $data['unit_of_measure'] ?? 'EA',
|
||||
'cost_price' => $data['cost_price'] ?? 0,
|
||||
'minimum_stock_level' => $data['minimum_stock_level'] ?? 0,
|
||||
'description' => $data['description'] ?? null,
|
||||
'is_active' => $data['is_active'] ?? $active,
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
}
|
||||
|
||||
return ['imported' => $imported, 'skipped' => $skipped, 'format' => $format];
|
||||
}
|
||||
|
||||
private function detectFormat(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): string
|
||||
{
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
// Forkoll sheets have "MATERIAL INVENTORY FOR SALE" somewhere in col A around row 3
|
||||
for ($r = 1; $r <= 5; $r++) {
|
||||
$val = strtolower(trim((string) $sheet->getCell('A' . $r)->getValue()));
|
||||
if (str_contains($val, 'material inventory for sale') || str_contains($val, 'forkoll')) {
|
||||
return 'forkoll';
|
||||
}
|
||||
}
|
||||
return 'template';
|
||||
}
|
||||
|
||||
private function extractFromForkoll(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
|
||||
{
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$maxRow = $sheet->getHighestRow();
|
||||
$items = [];
|
||||
$category = 'finished_good';
|
||||
|
||||
for ($r = 1; $r <= $maxRow; $r++) {
|
||||
$colA = trim((string) $sheet->getCell('A' . $r)->getValue());
|
||||
$colB = trim((string) $sheet->getCell('B' . $r)->getValue());
|
||||
$colC = trim((string) $sheet->getCell('C' . $r)->getValue());
|
||||
$colD = trim((string) $sheet->getCell('D' . $r)->getValue());
|
||||
|
||||
// Detect section header (text in col A, empty col B)
|
||||
if ($colA !== '' && $colB === '') {
|
||||
$lower = strtolower($colA);
|
||||
|
||||
// Skip SUB TOTAL rows
|
||||
foreach ($this->skipWords as $skip) {
|
||||
if (str_contains($lower, $skip)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Update current category from section map
|
||||
foreach ($this->sectionMap as $keyword => $cat) {
|
||||
if (str_contains($lower, $keyword)) {
|
||||
$category = $cat;
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip rows with no description or a formula
|
||||
if ($colB === '' || str_starts_with($colB, '=')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip header rows (col B says "DESCRIPTION", "Item Name", etc.)
|
||||
if (in_array(strtolower($colB), $this->headerWords)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$price = is_numeric($colD) ? (float) $colD : 0.0;
|
||||
|
||||
$items[] = [
|
||||
'item_name' => $colB,
|
||||
'unit_of_measure' => $colC ?: 'EA',
|
||||
'category' => $category,
|
||||
'cost_price' => $price,
|
||||
'is_active' => !str_contains(strtolower($colB), 'not in use'),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function extractFromTemplate(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
|
||||
{
|
||||
$rows = $spreadsheet->getActiveSheet()->toArray(null, true, true, false);
|
||||
$headers = array_map(
|
||||
fn($h) => strtolower(trim(str_replace('*', '', (string) $h))),
|
||||
$rows[0] ?? []
|
||||
);
|
||||
|
||||
$fields = ['item_name', 'unit_of_measure', 'category', 'cost_price', 'minimum_stock_level', 'description', 'is_active'];
|
||||
$map = [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$idx = array_search($field, $headers);
|
||||
if ($idx !== false) {
|
||||
$map[$field] = $idx;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($map['item_name'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach (array_slice($rows, 1) as $row) {
|
||||
$name = trim((string) ($row[$map['item_name']] ?? ''));
|
||||
if (empty($name) || str_starts_with($name, '*')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = ['item_name' => $name];
|
||||
foreach (array_diff($fields, ['item_name']) as $field) {
|
||||
if (isset($map[$field])) {
|
||||
$val = trim((string) ($row[$map[$field]] ?? ''));
|
||||
$entry[$field] = $val !== '' ? $val : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($entry['cost_price'])) {
|
||||
$entry['cost_price'] = is_numeric($entry['cost_price']) ? (float) $entry['cost_price'] : 0;
|
||||
}
|
||||
if (isset($entry['minimum_stock_level'])) {
|
||||
$entry['minimum_stock_level'] = is_numeric($entry['minimum_stock_level']) ? (float) $entry['minimum_stock_level'] : 0;
|
||||
}
|
||||
if (isset($entry['is_active'])) {
|
||||
$entry['is_active'] = in_array(strtolower($entry['is_active'] ?? ''), ['yes', 'true', '1', 'active', 'y']);
|
||||
}
|
||||
|
||||
$items[] = $entry;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
}
|
||||
50
app/Services/PurchaseStageService.php
Normal file
50
app/Services/PurchaseStageService.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\PurchaseRequest;
|
||||
|
||||
class PurchaseStageService
|
||||
{
|
||||
const STAGES = [
|
||||
'draft', 'gm_approval', 'rfq', 'quoting',
|
||||
'comparison', 'lpo', 'receiving', 'payment', 'complete',
|
||||
];
|
||||
|
||||
public function advance(PurchaseRequest $request): void
|
||||
{
|
||||
$current = array_search($request->stage, self::STAGES);
|
||||
if ($current === false || $current === count(self::STAGES) - 1) {
|
||||
return;
|
||||
}
|
||||
$request->update(['stage' => self::STAGES[$current + 1]]);
|
||||
}
|
||||
|
||||
public function setStage(PurchaseRequest $request, string $stage): void
|
||||
{
|
||||
abort_unless(in_array($stage, self::STAGES), 422, 'Invalid stage');
|
||||
$request->update(['stage' => $stage]);
|
||||
}
|
||||
|
||||
public function stageIndex(string $stage): int
|
||||
{
|
||||
$idx = array_search($stage, self::STAGES);
|
||||
return $idx === false ? 0 : $idx;
|
||||
}
|
||||
|
||||
public function stageLabel(string $stage): string
|
||||
{
|
||||
return match ($stage) {
|
||||
'draft' => 'Purchase Request',
|
||||
'gm_approval' => 'GM Signature',
|
||||
'rfq' => 'Select Suppliers',
|
||||
'quoting' => 'Awaiting Quotes',
|
||||
'comparison' => 'Quote Comparison',
|
||||
'lpo' => 'LPO Issued',
|
||||
'receiving' => 'Receiving Materials',
|
||||
'payment' => 'Payment',
|
||||
'complete' => 'Complete',
|
||||
default => ucfirst($stage),
|
||||
};
|
||||
}
|
||||
}
|
||||
57
app/Services/RfqInvitationService.php
Normal file
57
app/Services/RfqInvitationService.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\RfqInvitationMail;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\RfqInvitation;
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class RfqInvitationService
|
||||
{
|
||||
public function select(PurchaseRequest $purchaseRequest, Supplier $supplier, string $channel, array $itemIds = []): RfqInvitation
|
||||
{
|
||||
return RfqInvitation::create([
|
||||
'purchase_request_id' => $purchaseRequest->id,
|
||||
'supplier_id' => $supplier->id,
|
||||
'token' => bin2hex(random_bytes(32)),
|
||||
'channel' => $channel,
|
||||
'expires_at' => now()->addDays(14),
|
||||
'status' => 'pending',
|
||||
'item_ids' => empty($itemIds) ? null : $itemIds,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendInvitation(RfqInvitation $invitation): void
|
||||
{
|
||||
$invitation->update(['status' => 'sent', 'sent_at' => now()]);
|
||||
|
||||
$supplier = $invitation->supplier;
|
||||
if (in_array($invitation->channel, ['email', 'both']) && $supplier->email) {
|
||||
try {
|
||||
Mail::to($supplier->email)->send(new RfqInvitationMail($invitation));
|
||||
} catch (\Exception $e) {
|
||||
// Mail failure should not block flow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function invite(PurchaseRequest $purchaseRequest, Supplier $supplier, string $channel): RfqInvitation
|
||||
{
|
||||
$invitation = $this->select($purchaseRequest, $supplier, $channel);
|
||||
$this->sendInvitation($invitation);
|
||||
return $invitation;
|
||||
}
|
||||
|
||||
public function whatsappLink(RfqInvitation $invitation): string
|
||||
{
|
||||
$url = route('rfq.show', $invitation->token);
|
||||
$text = "Hello {$invitation->supplier->name},\n\n"
|
||||
. "You are invited to submit a quote for purchase request {$invitation->purchaseRequest->request_number}.\n\n"
|
||||
. "Please click the link below to submit your quote:\n{$url}\n\n"
|
||||
. "This link expires in 7 days and can only be used once.";
|
||||
$phone = preg_replace('/\D/', '', $invitation->supplier->phone ?? '');
|
||||
return 'https://wa.me/' . $phone . '?text=' . rawurlencode($text);
|
||||
}
|
||||
}
|
||||
272
app/Services/SupplierImportService.php
Normal file
272
app/Services/SupplierImportService.php
Normal file
@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Supplier;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
class SupplierImportService
|
||||
{
|
||||
private array $mrfStopWords = ['comments if any', 'lowest price', 'avg price', 'recommendation'];
|
||||
|
||||
public function import(string $filePath): array
|
||||
{
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
$format = $this->detectFormat($spreadsheet);
|
||||
|
||||
$rows = $format === 'mrf'
|
||||
? $this->extractFromMrf($spreadsheet)
|
||||
: $this->extractFromTemplate($spreadsheet);
|
||||
|
||||
$imported = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($rows as $data) {
|
||||
$name = trim($data['name'] ?? '');
|
||||
if (empty($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = Supplier::whereRaw('LOWER(name) = ?', [strtolower($name)])->first();
|
||||
|
||||
if ($existing) {
|
||||
// Update the existing record with any new/missing fields
|
||||
$existing->update($this->buildAttributes($data, true));
|
||||
$updated++;
|
||||
} else {
|
||||
Supplier::create($this->buildAttributes($data, false));
|
||||
$imported++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'imported' => $imported,
|
||||
'updated' => $updated,
|
||||
'skipped' => $skipped,
|
||||
'format' => $format,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildAttributes(array $data, bool $isUpdate): array
|
||||
{
|
||||
$attrs = [
|
||||
'contact_person' => $this->str($data['contact_person'] ?? ''),
|
||||
'email' => $this->str($data['email'] ?? ''),
|
||||
'secondary_email' => $this->str($data['secondary_email'] ?? ''),
|
||||
'phone' => $this->normalizePhone($data['phone'] ?? ''),
|
||||
'phone2' => $this->normalizePhone($data['phone2'] ?? ''),
|
||||
'whatsapp' => $this->normalizePhone($data['whatsapp'] ?? ''),
|
||||
'address' => $this->str($data['address'] ?? ''),
|
||||
'website' => $this->str($data['website'] ?? ''),
|
||||
'tax_number' => $this->str($data['tax_number'] ?? ''),
|
||||
'credit_terms' => $this->str($data['credit_terms'] ?? ''),
|
||||
'credit_days' => $this->parseCreditDays($data['credit_days'] ?? ''),
|
||||
'remarks' => $this->str($data['remarks'] ?? ''),
|
||||
'is_active' => $this->parseBoolean($data['is_active'] ?? 'yes'),
|
||||
];
|
||||
|
||||
if (!$isUpdate) {
|
||||
$attrs['name'] = trim($data['name']);
|
||||
$attrs['supplier_code'] = $this->str($data['supplier_code'] ?? '');
|
||||
$attrs['category'] = $this->str($data['category'] ?? '');
|
||||
} else {
|
||||
// On update: only fill category/code if not already set
|
||||
if (!empty($data['supplier_code'])) {
|
||||
$attrs['supplier_code'] = $this->str($data['supplier_code']);
|
||||
}
|
||||
if (!empty($data['category'])) {
|
||||
$attrs['category'] = $this->str($data['category']);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove null-equivalent values so we don't overwrite real data with blanks
|
||||
return array_filter($attrs, fn($v) => $v !== null && $v !== '');
|
||||
}
|
||||
|
||||
// ── Format detection ────────────────────────────────────────────────────
|
||||
|
||||
private function detectFormat(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): string
|
||||
{
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$a4 = strtolower(trim((string) $sheet->getCell('A4')->getValue()));
|
||||
return $a4 === 's.no' ? 'mrf' : 'template';
|
||||
}
|
||||
|
||||
// ── MRF extraction ──────────────────────────────────────────────────────
|
||||
|
||||
private function extractFromMrf(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
|
||||
{
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$suppliers = [];
|
||||
|
||||
for ($col = 7; $col <= 50; $col++) {
|
||||
$coord = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($col) . '4';
|
||||
$value = trim((string) $sheet->getCell($coord)->getValue());
|
||||
|
||||
if (empty($value) || in_array(strtolower($value), $this->mrfStopWords)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$suppliers[] = ['name' => $value];
|
||||
}
|
||||
|
||||
return $suppliers;
|
||||
}
|
||||
|
||||
// ── Template / Unified format extraction ────────────────────────────────
|
||||
|
||||
private function extractFromTemplate(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
|
||||
{
|
||||
$rows = $spreadsheet->getActiveSheet()->toArray(null, true, true, false);
|
||||
$headers = array_map(
|
||||
fn($h) => strtolower(trim(str_replace(['*', '_'], [' ', ' '], (string) $h))),
|
||||
$rows[0] ?? []
|
||||
);
|
||||
|
||||
// Map normalised header text → internal field name
|
||||
$aliases = [
|
||||
'supplier id' => 'supplier_code',
|
||||
'name' => 'name',
|
||||
'company name' => 'name',
|
||||
'category' => 'category',
|
||||
'contact person' => 'contact_person',
|
||||
'primary email' => 'email',
|
||||
'email' => 'email',
|
||||
'secondary email' => 'secondary_email',
|
||||
'phone 1' => 'phone',
|
||||
'phone' => 'phone',
|
||||
'phone 2' => 'phone2',
|
||||
'whatsapp number' => 'whatsapp',
|
||||
'whatsapp' => 'whatsapp',
|
||||
'address' => 'address',
|
||||
'website' => 'website',
|
||||
'credit (y/n)' => 'credit_terms',
|
||||
'credit days' => 'credit_days',
|
||||
'tax number' => 'tax_number',
|
||||
'is active' => 'is_active',
|
||||
'remarks / key details' => 'remarks',
|
||||
'remarks' => 'remarks',
|
||||
];
|
||||
|
||||
$map = [];
|
||||
foreach ($headers as $idx => $header) {
|
||||
if (isset($aliases[$header]) && !isset($map[$aliases[$header]])) {
|
||||
$map[$aliases[$header]] = $idx;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($map['name'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$suppliers = [];
|
||||
foreach (array_slice($rows, 1) as $row) {
|
||||
$name = trim((string) ($row[$map['name']] ?? ''));
|
||||
|
||||
if (empty($name) || str_starts_with($name, '*')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->looksLikeSupplierName($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = ['name' => $name];
|
||||
foreach (array_keys($aliases) as $alias) {
|
||||
$field = $aliases[$alias];
|
||||
if ($field === 'name') continue;
|
||||
if (isset($map[$field]) && !isset($entry[$field])) {
|
||||
$entry[$field] = trim((string) ($row[$map[$field]] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
$suppliers[] = $entry;
|
||||
}
|
||||
|
||||
return $suppliers;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reject rows that are specification text, not company names.
|
||||
* Patterns: starts with digit, measurement strings, known junk phrases.
|
||||
*/
|
||||
private function looksLikeSupplierName(string $name): bool
|
||||
{
|
||||
// Starts with a digit → quantity/spec row
|
||||
if (preg_match('/^\d/', $name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lower = strtolower($name);
|
||||
|
||||
$junkPhrases = [
|
||||
'gdcd minimum', 'approx. quantity', 'approx quantity',
|
||||
'per 200 m', 'per 30 m', 'coverage mandatory',
|
||||
'hydrant within', 'smoke extraction', 'mandatory for',
|
||||
'required for buildings', '–', '—',
|
||||
];
|
||||
|
||||
foreach ($junkPhrases as $phrase) {
|
||||
if (str_contains($lower, $phrase)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise phone numbers to +COUNTRYCODELOCAL (digits only after +).
|
||||
* Examples: "+973 3318 8311" → "+97333188311"
|
||||
* "39209304" (8-digit Bahrain local) → "+97339209304"
|
||||
*/
|
||||
public function normalizePhone(string $raw): string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Strip parenthetical extensions: (ext 118), (ext 201, 202)
|
||||
$val = preg_replace('/\(\s*ext[^)]*\)/i', '', $raw);
|
||||
|
||||
// Strip trailing extensions: " Ext. 7438", "x 26", "- 26" (1-4 digits at end after dash/space)
|
||||
$val = preg_replace('/\s+(?:ext\.?|x)\s*[\d,\s]+$/i', '', $val);
|
||||
$val = preg_replace('/\s*[-–]\s*\d{1,4}\s*$/', '', $val);
|
||||
|
||||
// Extract only digits
|
||||
$digits = preg_replace('/[^\d]/', '', $val);
|
||||
|
||||
if ($digits === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 8-digit Bahrain local number → prepend 973
|
||||
if (strlen($digits) === 8) {
|
||||
return '+973' . $digits;
|
||||
}
|
||||
|
||||
// Already has country code as prefix
|
||||
return '+' . $digits;
|
||||
}
|
||||
|
||||
private function parseCreditDays(string $val): ?int
|
||||
{
|
||||
$digits = preg_replace('/[^\d]/', '', $val);
|
||||
return $digits !== '' ? (int) $digits : null;
|
||||
}
|
||||
|
||||
private function parseBoolean(string $value): bool
|
||||
{
|
||||
return in_array(strtolower(trim($value)), ['yes', 'true', '1', 'active', 'y']);
|
||||
}
|
||||
|
||||
private function str(string $val): ?string
|
||||
{
|
||||
$v = trim($val);
|
||||
return $v !== '' ? $v : null;
|
||||
}
|
||||
}
|
||||
17
app/View/Components/AppLayout.php
Normal file
17
app/View/Components/AppLayout.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AppLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.app');
|
||||
}
|
||||
}
|
||||
17
app/View/Components/GuestLayout.php
Normal file
17
app/View/Components/GuestLayout.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\View\Components;
|
||||
|
||||
use Illuminate\View\Component;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class GuestLayout extends Component
|
||||
{
|
||||
/**
|
||||
* Get the view / contents that represents the component.
|
||||
*/
|
||||
public function render(): View
|
||||
{
|
||||
return view('layouts.guest');
|
||||
}
|
||||
}
|
||||
18
artisan
Normal file
18
artisan
Normal file
@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
22
bootstrap/app.php
Normal file
22
bootstrap/app.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
})->create();
|
||||
2
bootstrap/cache/.gitignore
vendored
Normal file
2
bootstrap/cache/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
7
bootstrap/providers.php
Normal file
7
bootstrap/providers.php
Normal file
@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
];
|
||||
90
composer.json
Normal file
90
composer.json
Normal file
@ -0,0 +1,90 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"phpoffice/phpspreadsheet": "^5.7",
|
||||
"spatie/laravel-permission": "^6.25"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/breeze": "^2.4",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^11.5.50"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
9447
composer.lock
generated
Normal file
9447
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
config/app.php
Normal file
126
config/app.php
Normal file
@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user