Barcode scanning and GTIN mapping
This commit is contained in:
+119
-24
@@ -18,11 +18,13 @@ from .models import (
|
|||||||
Batch,
|
Batch,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
User,
|
User,
|
||||||
|
GtinMapping,
|
||||||
)
|
)
|
||||||
from .auth import hash_password, verify_password, create_access_token, get_current_user, get_current_admin_user, get_current_non_readonly_user, ACCESS_TOKEN_EXPIRE_MINUTES
|
from .auth import hash_password, verify_password, create_access_token, get_current_user, get_current_admin_user, get_current_non_readonly_user, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
from .mqtt_service import publish_label_print_with_response
|
from .mqtt_service import publish_label_print_with_response
|
||||||
from .migrate_to_roles import migrate_users_table
|
from .migrate_to_roles import migrate_users_table
|
||||||
from .migrate_compliance import migrate_compliance_schema
|
from .migrate_compliance import migrate_compliance_schema
|
||||||
|
from .migrate_gtin import migrate_gtin_schema
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Run migration to convert is_admin to role
|
# Run migration to convert is_admin to role
|
||||||
@@ -39,6 +41,11 @@ except Exception as e:
|
|||||||
# Create tables
|
# Create tables
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
try:
|
||||||
|
migrate_gtin_schema()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: GTIN migration failed: {e}. Continuing anyway...")
|
||||||
|
|
||||||
# Seed default locations after table creation.
|
# Seed default locations after table creation.
|
||||||
try:
|
try:
|
||||||
migrate_compliance_schema()
|
migrate_compliance_schema()
|
||||||
@@ -138,14 +145,37 @@ class BatchDisposeRequest(BaseModel):
|
|||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class GtinMappingCreate(BaseModel):
|
||||||
|
gtin: str
|
||||||
|
drug_variant_id: int
|
||||||
|
variant_pack_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class GtinMappingResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
gtin: str
|
||||||
|
drug_variant_id: int
|
||||||
|
variant_pack_id: int
|
||||||
|
drug_id: int
|
||||||
|
drug_name: str
|
||||||
|
variant_strength: str
|
||||||
|
variant_unit: str
|
||||||
|
pack_label: str
|
||||||
|
pack_size_in_base_units: float
|
||||||
|
pack_unit_name: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class BatchResponse(BaseModel):
|
class BatchResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
drug_variant_id: int
|
drug_variant_id: int
|
||||||
batch_number: str
|
batch_number: str
|
||||||
quantity: float
|
quantity: float
|
||||||
received_pack_id: Optional[int] = None
|
received_pack_id: Optional[int] = None
|
||||||
received_pack_label: Optional[str] = None
|
received_pack_unit_name: Optional[str] = None
|
||||||
received_pack_count: Optional[float] = None
|
|
||||||
received_pack_size_snapshot: Optional[float] = None
|
received_pack_size_snapshot: Optional[float] = None
|
||||||
current_full_pack_count: Optional[float] = None
|
current_full_pack_count: Optional[float] = None
|
||||||
current_loose_base_units: Optional[float] = None
|
current_loose_base_units: Optional[float] = None
|
||||||
@@ -187,14 +217,12 @@ class DrugVariantUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class VariantPackCreate(BaseModel):
|
class VariantPackCreate(BaseModel):
|
||||||
label: str
|
|
||||||
pack_unit_name: str
|
pack_unit_name: str
|
||||||
pack_size_in_base_units: float
|
pack_size_in_base_units: float
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
class VariantPackUpdate(BaseModel):
|
class VariantPackUpdate(BaseModel):
|
||||||
label: Optional[str] = None
|
|
||||||
pack_unit_name: Optional[str] = None
|
pack_unit_name: Optional[str] = None
|
||||||
pack_size_in_base_units: Optional[float] = None
|
pack_size_in_base_units: Optional[float] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
@@ -203,7 +231,6 @@ class VariantPackUpdate(BaseModel):
|
|||||||
class VariantPackResponse(BaseModel):
|
class VariantPackResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
drug_variant_id: int
|
drug_variant_id: int
|
||||||
label: str
|
|
||||||
pack_unit_name: str
|
pack_unit_name: str
|
||||||
pack_size_in_base_units: float
|
pack_size_in_base_units: float
|
||||||
is_active: bool
|
is_active: bool
|
||||||
@@ -383,7 +410,7 @@ def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
|
|||||||
"batch_number": batch.batch_number,
|
"batch_number": batch.batch_number,
|
||||||
"quantity": batch.quantity,
|
"quantity": batch.quantity,
|
||||||
"received_pack_id": batch.received_pack_id,
|
"received_pack_id": batch.received_pack_id,
|
||||||
"received_pack_label": pack.label if pack else None,
|
"received_pack_unit_name": pack.pack_unit_name if pack else None,
|
||||||
"received_pack_count": batch.received_pack_count,
|
"received_pack_count": batch.received_pack_count,
|
||||||
"received_pack_size_snapshot": batch.received_pack_size_snapshot,
|
"received_pack_size_snapshot": batch.received_pack_size_snapshot,
|
||||||
"current_full_pack_count": batch.current_full_pack_count,
|
"current_full_pack_count": batch.current_full_pack_count,
|
||||||
@@ -429,7 +456,6 @@ def serialize_variant_pack(pack: VariantPack) -> Dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"id": pack.id,
|
"id": pack.id,
|
||||||
"drug_variant_id": pack.drug_variant_id,
|
"drug_variant_id": pack.drug_variant_id,
|
||||||
"label": pack.label,
|
|
||||||
"pack_unit_name": pack.pack_unit_name,
|
"pack_unit_name": pack.pack_unit_name,
|
||||||
"pack_size_in_base_units": pack.pack_size_in_base_units,
|
"pack_size_in_base_units": pack.pack_size_in_base_units,
|
||||||
"is_active": pack.is_active,
|
"is_active": pack.is_active,
|
||||||
@@ -1050,15 +1076,12 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session =
|
|||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# Ensure each variant has at least one active default 1:1 pack representation.
|
# Ensure each variant has at least one active default 1:1 pack representation.
|
||||||
db.add(
|
db.add(VariantPack(
|
||||||
VariantPack(
|
|
||||||
drug_variant_id=db_variant.id,
|
drug_variant_id=db_variant.id,
|
||||||
label=f"1 {base_unit}",
|
|
||||||
pack_unit_name=base_unit,
|
pack_unit_name=base_unit,
|
||||||
pack_size_in_base_units=1,
|
pack_size_in_base_units=1,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
))
|
||||||
)
|
|
||||||
|
|
||||||
write_audit_log(
|
write_audit_log(
|
||||||
db,
|
db,
|
||||||
@@ -1232,10 +1255,7 @@ def create_variant_pack(
|
|||||||
if not variant:
|
if not variant:
|
||||||
raise HTTPException(status_code=404, detail="Drug variant not found")
|
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||||
|
|
||||||
label = payload.label.strip()
|
|
||||||
pack_unit_name = payload.pack_unit_name.strip()
|
pack_unit_name = payload.pack_unit_name.strip()
|
||||||
if not label:
|
|
||||||
raise HTTPException(status_code=400, detail="Pack label cannot be empty")
|
|
||||||
if not pack_unit_name:
|
if not pack_unit_name:
|
||||||
raise HTTPException(status_code=400, detail="Pack unit name cannot be empty")
|
raise HTTPException(status_code=400, detail="Pack unit name cannot be empty")
|
||||||
if payload.pack_size_in_base_units <= 0:
|
if payload.pack_size_in_base_units <= 0:
|
||||||
@@ -1243,7 +1263,6 @@ def create_variant_pack(
|
|||||||
|
|
||||||
row = VariantPack(
|
row = VariantPack(
|
||||||
drug_variant_id=variant_id,
|
drug_variant_id=variant_id,
|
||||||
label=label,
|
|
||||||
pack_unit_name=pack_unit_name,
|
pack_unit_name=pack_unit_name,
|
||||||
pack_size_in_base_units=payload.pack_size_in_base_units,
|
pack_size_in_base_units=payload.pack_size_in_base_units,
|
||||||
is_active=payload.is_active,
|
is_active=payload.is_active,
|
||||||
@@ -1257,7 +1276,6 @@ def create_variant_pack(
|
|||||||
actor=current_user,
|
actor=current_user,
|
||||||
details={
|
details={
|
||||||
"variant_id": variant_id,
|
"variant_id": variant_id,
|
||||||
"label": label,
|
|
||||||
"pack_unit_name": pack_unit_name,
|
"pack_unit_name": pack_unit_name,
|
||||||
"pack_size_in_base_units": payload.pack_size_in_base_units,
|
"pack_size_in_base_units": payload.pack_size_in_base_units,
|
||||||
"is_active": payload.is_active,
|
"is_active": payload.is_active,
|
||||||
@@ -1281,12 +1299,6 @@ def update_variant_pack(
|
|||||||
|
|
||||||
before = serialize_variant_pack(row)
|
before = serialize_variant_pack(row)
|
||||||
|
|
||||||
if payload.label is not None:
|
|
||||||
cleaned = payload.label.strip()
|
|
||||||
if not cleaned:
|
|
||||||
raise HTTPException(status_code=400, detail="Pack label cannot be empty")
|
|
||||||
row.label = cleaned
|
|
||||||
|
|
||||||
if payload.pack_unit_name is not None:
|
if payload.pack_unit_name is not None:
|
||||||
cleaned = payload.pack_unit_name.strip()
|
cleaned = payload.pack_unit_name.strip()
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
@@ -2200,7 +2212,7 @@ def report_batch_attention(
|
|||||||
"location": location.name,
|
"location": location.name,
|
||||||
"expiry_date": batch.expiry_date,
|
"expiry_date": batch.expiry_date,
|
||||||
"status": "expired",
|
"status": "expired",
|
||||||
"received_pack_label": None,
|
"received_pack_unit_name": None,
|
||||||
"current_full_pack_count": batch.current_full_pack_count,
|
"current_full_pack_count": batch.current_full_pack_count,
|
||||||
"current_loose_base_units": batch.current_loose_base_units,
|
"current_loose_base_units": batch.current_loose_base_units,
|
||||||
"is_controlled": bool(drug.is_controlled),
|
"is_controlled": bool(drug.is_controlled),
|
||||||
@@ -2450,5 +2462,88 @@ def print_notes(notes_request: NotesPrintRequest, current_user: User = Depends(g
|
|||||||
detail=f"Error sending notes print request: {str(e)}"
|
detail=f"Error sending notes print request: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get("/gtin/{gtin}", response_model=GtinMappingResponse)
|
||||||
|
def get_gtin_mapping(
|
||||||
|
gtin: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
mapping = db.query(GtinMapping).filter(GtinMapping.gtin == gtin).first()
|
||||||
|
if not mapping:
|
||||||
|
raise HTTPException(status_code=404, detail="GTIN not found")
|
||||||
|
|
||||||
|
variant = db.query(DrugVariant).filter(DrugVariant.id == mapping.drug_variant_id).first()
|
||||||
|
drug = db.query(Drug).filter(Drug.id == variant.drug_id).first() if variant else None
|
||||||
|
pack = db.query(VariantPack).filter(VariantPack.id == mapping.variant_pack_id).first()
|
||||||
|
|
||||||
|
if not variant or not drug or not pack:
|
||||||
|
# Referenced records no longer exist — delete the stale mapping and treat as unknown
|
||||||
|
db.delete(mapping)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=404, detail="GTIN not found")
|
||||||
|
|
||||||
|
return GtinMappingResponse(
|
||||||
|
id=mapping.id,
|
||||||
|
gtin=mapping.gtin,
|
||||||
|
drug_variant_id=mapping.drug_variant_id,
|
||||||
|
variant_pack_id=mapping.variant_pack_id,
|
||||||
|
drug_id=drug.id,
|
||||||
|
drug_name=drug.name,
|
||||||
|
variant_strength=variant.strength,
|
||||||
|
variant_unit=variant.unit,
|
||||||
|
pack_label=f"{pack.pack_unit_name} of {int(pack.pack_size_in_base_units) if pack.pack_size_in_base_units == int(pack.pack_size_in_base_units) else pack.pack_size_in_base_units}",
|
||||||
|
pack_size_in_base_units=pack.pack_size_in_base_units,
|
||||||
|
pack_unit_name=pack.pack_unit_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/gtin", response_model=GtinMappingResponse, status_code=201)
|
||||||
|
def create_gtin_mapping(
|
||||||
|
body: GtinMappingCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_non_readonly_user),
|
||||||
|
):
|
||||||
|
existing = db.query(GtinMapping).filter(GtinMapping.gtin == body.gtin).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail="GTIN mapping already exists")
|
||||||
|
|
||||||
|
variant = db.query(DrugVariant).filter(DrugVariant.id == body.drug_variant_id).first()
|
||||||
|
if not variant:
|
||||||
|
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||||
|
|
||||||
|
pack = db.query(VariantPack).filter(
|
||||||
|
VariantPack.id == body.variant_pack_id,
|
||||||
|
VariantPack.drug_variant_id == body.drug_variant_id,
|
||||||
|
).first()
|
||||||
|
if not pack:
|
||||||
|
raise HTTPException(status_code=404, detail="Pack not found for this variant")
|
||||||
|
|
||||||
|
drug = db.query(Drug).filter(Drug.id == variant.drug_id).first()
|
||||||
|
|
||||||
|
mapping = GtinMapping(
|
||||||
|
gtin=body.gtin,
|
||||||
|
drug_variant_id=body.drug_variant_id,
|
||||||
|
variant_pack_id=body.variant_pack_id,
|
||||||
|
created_by_user_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(mapping)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(mapping)
|
||||||
|
|
||||||
|
return GtinMappingResponse(
|
||||||
|
id=mapping.id,
|
||||||
|
gtin=mapping.gtin,
|
||||||
|
drug_variant_id=mapping.drug_variant_id,
|
||||||
|
variant_pack_id=mapping.variant_pack_id,
|
||||||
|
drug_id=drug.id,
|
||||||
|
drug_name=drug.name,
|
||||||
|
variant_strength=variant.strength,
|
||||||
|
variant_unit=variant.unit,
|
||||||
|
pack_label=f"{pack.pack_unit_name} of {int(pack.pack_size_in_base_units) if pack.pack_size_in_base_units == int(pack.pack_size_in_base_units) else pack.pack_size_in_base_units}",
|
||||||
|
pack_size_in_base_units=pack.pack_size_in_base_units,
|
||||||
|
pack_unit_name=pack.pack_unit_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Include router with /api prefix
|
# Include router with /api prefix
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
GTIN mapping table migration.
|
||||||
|
|
||||||
|
Creates the gtin_mappings table if it does not already exist.
|
||||||
|
Idempotent and safe to run on every startup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_DB_URL = "sqlite:///./data/drugs.db"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_sqlite_path(db_url: str) -> Path | None:
|
||||||
|
if not db_url.startswith("sqlite:///"):
|
||||||
|
print(f"Unsupported database URL for GTIN migration: {db_url}")
|
||||||
|
return None
|
||||||
|
raw_path = db_url.replace("sqlite:///", "")
|
||||||
|
if raw_path.startswith("/"):
|
||||||
|
return Path(raw_path)
|
||||||
|
return Path(raw_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(cursor: sqlite3.Cursor, table_name: str) -> bool:
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
(table_name,),
|
||||||
|
)
|
||||||
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(cursor: sqlite3.Cursor, table_name: str, column_name: str) -> bool:
|
||||||
|
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||||
|
return any(row[1] == column_name for row in cursor.fetchall())
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_gtin_schema() -> None:
|
||||||
|
"""Create gtin_mappings table if it does not exist, and drop label from variant_packs."""
|
||||||
|
db_url = os.getenv("DATABASE_URL", DEFAULT_DB_URL)
|
||||||
|
db_path = _resolve_sqlite_path(db_url)
|
||||||
|
if db_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"Database does not exist at {db_path}, skipping GTIN migration")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Running GTIN migration on {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not _table_exists(cursor, "gtin_mappings"):
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE gtin_mappings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
gtin VARCHAR(14) NOT NULL,
|
||||||
|
drug_variant_id INTEGER NOT NULL REFERENCES drug_variants(id),
|
||||||
|
variant_pack_id INTEGER NOT NULL REFERENCES variant_packs(id),
|
||||||
|
created_by_user_id INTEGER REFERENCES users(id),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cursor.execute("CREATE UNIQUE INDEX ix_gtin_mappings_gtin ON gtin_mappings (gtin)")
|
||||||
|
cursor.execute("CREATE INDEX ix_gtin_mappings_drug_variant_id ON gtin_mappings (drug_variant_id)")
|
||||||
|
cursor.execute("CREATE INDEX ix_gtin_mappings_variant_pack_id ON gtin_mappings (variant_pack_id)")
|
||||||
|
print("Created gtin_mappings table")
|
||||||
|
else:
|
||||||
|
print("gtin_mappings table already exists, skipping")
|
||||||
|
|
||||||
|
# Drop label column from variant_packs if it still exists
|
||||||
|
if _table_exists(cursor, "variant_packs") and _column_exists(cursor, "variant_packs", "label"):
|
||||||
|
print("Dropping label column from variant_packs")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE variant_packs_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
drug_variant_id INTEGER NOT NULL REFERENCES drug_variants(id),
|
||||||
|
pack_unit_name VARCHAR NOT NULL DEFAULT 'pack',
|
||||||
|
pack_size_in_base_units FLOAT NOT NULL DEFAULT 1,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO variant_packs_new (id, drug_variant_id, pack_unit_name, pack_size_in_base_units, is_active, created_at, updated_at)
|
||||||
|
SELECT id, drug_variant_id, pack_unit_name, pack_size_in_base_units, is_active, created_at, updated_at
|
||||||
|
FROM variant_packs
|
||||||
|
""")
|
||||||
|
# Re-create indexes
|
||||||
|
cursor.execute("DROP INDEX IF EXISTS ix_variant_packs_drug_variant_id")
|
||||||
|
cursor.execute("DROP TABLE variant_packs")
|
||||||
|
cursor.execute("ALTER TABLE variant_packs_new RENAME TO variant_packs")
|
||||||
|
cursor.execute("CREATE INDEX ix_variant_packs_drug_variant_id ON variant_packs (drug_variant_id)")
|
||||||
|
print("Dropped label column from variant_packs")
|
||||||
|
else:
|
||||||
|
print("variant_packs.label already absent, skipping")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
+11
-1
@@ -41,7 +41,6 @@ class VariantPack(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
|
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
|
||||||
label = Column(String, nullable=False)
|
|
||||||
pack_unit_name = Column(String, nullable=False, default="pack")
|
pack_unit_name = Column(String, nullable=False, default="pack")
|
||||||
pack_size_in_base_units = Column(Float, nullable=False, default=1)
|
pack_size_in_base_units = Column(Float, nullable=False, default=1)
|
||||||
is_active = Column(Boolean, nullable=False, default=True)
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
@@ -109,6 +108,17 @@ class DispensingAllocation(Base):
|
|||||||
quantity = Column(Float, nullable=False)
|
quantity = Column(Float, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class GtinMapping(Base):
|
||||||
|
__tablename__ = "gtin_mappings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
gtin = Column(String(14), unique=True, index=True, nullable=False)
|
||||||
|
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
|
||||||
|
variant_pack_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=False, index=True)
|
||||||
|
created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(Base):
|
class AuditLog(Base):
|
||||||
__tablename__ = "audit_logs"
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
|||||||
+659
-49
@@ -13,6 +13,26 @@ let deliveryLineCounter = 0;
|
|||||||
let deliveryLocations = [];
|
let deliveryLocations = [];
|
||||||
let currentDispenseBatches = [];
|
let currentDispenseBatches = [];
|
||||||
let currentDispenseLegacyQuantity = 0;
|
let currentDispenseLegacyQuantity = 0;
|
||||||
|
let _gtinMappingPendingRefresh = false;
|
||||||
|
let _gtinMappingPendingVariantId = null;
|
||||||
|
let _gtinMappingPendingRestore = null; // { drugId, variantId, packId } — auto-select after reload
|
||||||
|
let _gtinMappingWaitingForNewDrug = null; // Set of drug IDs before add-drug; resolved on first loadDrugs
|
||||||
|
|
||||||
|
/** Build a human-readable pack label from pack fields, e.g. "Box of 28" */
|
||||||
|
function packLabel(packOrUnitName, packSize) {
|
||||||
|
// Accept either (pack object) or (unit_name string, size number)
|
||||||
|
let unitName, size;
|
||||||
|
if (typeof packOrUnitName === 'object' && packOrUnitName !== null) {
|
||||||
|
unitName = packOrUnitName.pack_unit_name;
|
||||||
|
size = packOrUnitName.pack_size_in_base_units;
|
||||||
|
} else {
|
||||||
|
unitName = packOrUnitName;
|
||||||
|
size = packSize;
|
||||||
|
}
|
||||||
|
const displaySize = size === Math.floor(size) ? Math.floor(size) : size;
|
||||||
|
const unit = String(unitName || 'pack');
|
||||||
|
return `${unit.charAt(0).toUpperCase()}${unit.slice(1)} of ${displaySize}`;
|
||||||
|
}
|
||||||
|
|
||||||
function resetDisposeBatchModal() {
|
function resetDisposeBatchModal() {
|
||||||
const form = document.getElementById('disposeBatchForm');
|
const form = document.getElementById('disposeBatchForm');
|
||||||
@@ -132,10 +152,12 @@ function showMainApp() {
|
|||||||
const addDrugBtn = document.getElementById('addDrugBtn');
|
const addDrugBtn = document.getElementById('addDrugBtn');
|
||||||
const dispenseBtn = document.getElementById('dispenseBtn');
|
const dispenseBtn = document.getElementById('dispenseBtn');
|
||||||
const printNotesBtn = document.getElementById('printNotesBtn');
|
const printNotesBtn = document.getElementById('printNotesBtn');
|
||||||
|
const receiveDeliveryBtn = document.getElementById('receiveDeliveryBtn');
|
||||||
|
|
||||||
if (addDrugBtn) addDrugBtn.style.display = isReadOnly ? 'none' : 'block';
|
if (addDrugBtn) addDrugBtn.style.display = isReadOnly ? 'none' : 'block';
|
||||||
if (dispenseBtn) dispenseBtn.style.display = isReadOnly ? 'none' : 'block';
|
if (dispenseBtn) dispenseBtn.style.display = isReadOnly ? 'none' : 'block';
|
||||||
if (printNotesBtn) printNotesBtn.style.display = isReadOnly ? 'none' : 'block';
|
if (printNotesBtn) printNotesBtn.style.display = isReadOnly ? 'none' : 'block';
|
||||||
|
if (receiveDeliveryBtn) receiveDeliveryBtn.style.display = isReadOnly ? 'none' : 'block';
|
||||||
|
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
loadDrugs();
|
loadDrugs();
|
||||||
@@ -287,7 +309,10 @@ function setupEventListeners() {
|
|||||||
|
|
||||||
const receiveDeliveryForm = document.getElementById('receiveDeliveryForm');
|
const receiveDeliveryForm = document.getElementById('receiveDeliveryForm');
|
||||||
if (receiveDeliveryForm) receiveDeliveryForm.addEventListener('submit', handleReceiveDelivery);
|
if (receiveDeliveryForm) receiveDeliveryForm.addEventListener('submit', handleReceiveDelivery);
|
||||||
if (cancelReceiveDeliveryBtn) cancelReceiveDeliveryBtn.addEventListener('click', () => closeModal(receiveDeliveryModal));
|
if (cancelReceiveDeliveryBtn) cancelReceiveDeliveryBtn.addEventListener('click', () => {
|
||||||
|
_detachDeliveryBarcodeListener();
|
||||||
|
closeModal(receiveDeliveryModal);
|
||||||
|
});
|
||||||
if (addDeliveryLineBtn) addDeliveryLineBtn.addEventListener('click', () => appendDeliveryLine());
|
if (addDeliveryLineBtn) addDeliveryLineBtn.addEventListener('click', () => appendDeliveryLine());
|
||||||
if (addVariantFromDeliveryBtn) addVariantFromDeliveryBtn.addEventListener('click', handleAddVariantFromDelivery);
|
if (addVariantFromDeliveryBtn) addVariantFromDeliveryBtn.addEventListener('click', handleAddVariantFromDelivery);
|
||||||
if (addPackSizeFromDeliveryBtn) addPackSizeFromDeliveryBtn.addEventListener('click', openAddPackSizeFromDeliveryModal);
|
if (addPackSizeFromDeliveryBtn) addPackSizeFromDeliveryBtn.addEventListener('click', openAddPackSizeFromDeliveryModal);
|
||||||
@@ -337,6 +362,7 @@ function setupEventListeners() {
|
|||||||
|
|
||||||
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
|
if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal));
|
||||||
if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal));
|
if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal));
|
||||||
|
if (receiveDeliveryBtn) receiveDeliveryBtn.addEventListener('click', openReceiveDeliveryModal);
|
||||||
if (dispenseBtn) dispenseBtn.addEventListener('click', () => {
|
if (dispenseBtn) dispenseBtn.addEventListener('click', () => {
|
||||||
updateDispenseDrugSelect();
|
updateDispenseDrugSelect();
|
||||||
updateDispenseModeUi();
|
updateDispenseModeUi();
|
||||||
@@ -382,6 +408,9 @@ function setupEventListeners() {
|
|||||||
if (modal?.id === 'disposeBatchModal') {
|
if (modal?.id === 'disposeBatchModal') {
|
||||||
resetDisposeBatchModal();
|
resetDisposeBatchModal();
|
||||||
}
|
}
|
||||||
|
if (modal?.id === 'receiveDeliveryModal') {
|
||||||
|
_detachDeliveryBarcodeListener();
|
||||||
|
}
|
||||||
closeModal(modal);
|
closeModal(modal);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -470,6 +499,54 @@ async function loadDrugs() {
|
|||||||
updateLocationFilterOptions();
|
updateLocationFilterOptions();
|
||||||
renderDrugs();
|
renderDrugs();
|
||||||
updateDispenseDrugSelect();
|
updateDispenseDrugSelect();
|
||||||
|
|
||||||
|
if (_gtinMappingPendingRefresh) {
|
||||||
|
_gtinMappingPendingRefresh = false;
|
||||||
|
const restore = _gtinMappingPendingRestore || {};
|
||||||
|
_gtinMappingPendingRestore = null;
|
||||||
|
_gtinMappingPendingVariantId = null;
|
||||||
|
|
||||||
|
// Resolve new variant by diffing (add-variant flow)
|
||||||
|
if (restore._existingVariantIds && restore.drugId) {
|
||||||
|
const drug = allDrugs.find(d => d.id === restore.drugId);
|
||||||
|
const newVariant = drug?.variants?.find(v => !restore._existingVariantIds.has(v.id));
|
||||||
|
if (newVariant) {
|
||||||
|
restore.variantId = newVariant.id;
|
||||||
|
// If no pack snapshot, all packs are new — pick the first active one
|
||||||
|
if (!restore._existingPackIds) {
|
||||||
|
const firstPack = getActivePacksForVariant(newVariant)?.[0];
|
||||||
|
if (firstPack) restore.packId = firstPack.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Resolve new pack by diffing (add-pack flow)
|
||||||
|
if (restore._existingPackIds && restore.drugId && restore.variantId) {
|
||||||
|
const drug = allDrugs.find(d => d.id === restore.drugId);
|
||||||
|
const variant = drug?.variants?.find(v => v.id === restore.variantId);
|
||||||
|
const newPack = getActivePacksForVariant(variant)?.find(p => !restore._existingPackIds.has(p.id));
|
||||||
|
if (newPack) restore.packId = newPack.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
_reinitGtinMappingModal(restore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After handleAddDrug's loadDrugs fires: find the newly created drug and set up
|
||||||
|
// _gtinMappingPendingRefresh so that when handleAddVariant calls loadDrugs next,
|
||||||
|
// we auto-select drug + new variant in the GTIN modal.
|
||||||
|
if (_gtinMappingWaitingForNewDrug) {
|
||||||
|
const newDrug = allDrugs.find(d => !_gtinMappingWaitingForNewDrug.has(d.id));
|
||||||
|
_gtinMappingWaitingForNewDrug = null;
|
||||||
|
if (newDrug) {
|
||||||
|
// handleAddDrug will now open addVariantModal — prepare to catch that save
|
||||||
|
_gtinMappingPendingRestore = {
|
||||||
|
drugId: newDrug.id,
|
||||||
|
variantId: null,
|
||||||
|
packId: null,
|
||||||
|
_existingVariantIds: new Set((newDrug.variants || []).map(v => v.id))
|
||||||
|
};
|
||||||
|
_gtinMappingPendingRefresh = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading drugs:', error);
|
console.error('Error loading drugs:', error);
|
||||||
document.getElementById('drugsList').innerHTML =
|
document.getElementById('drugsList').innerHTML =
|
||||||
@@ -745,7 +822,7 @@ function populateDispensePackSelect(variant) {
|
|||||||
activePacks.forEach(pack => {
|
activePacks.forEach(pack => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = String(pack.id);
|
option.value = String(pack.id);
|
||||||
option.textContent = `${pack.label} (${pack.pack_size_in_base_units} ${variant.unit})`;
|
option.textContent = `${packLabel(pack)} (${pack.pack_size_in_base_units} ${variant.unit})`;
|
||||||
packSelect.appendChild(option);
|
packSelect.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -872,7 +949,7 @@ function renderVariantInventoryDetails(variant) {
|
|||||||
const packsHtml = activePacks.length > 0
|
const packsHtml = activePacks.length > 0
|
||||||
? activePacks.map(pack => `
|
? activePacks.map(pack => `
|
||||||
<div style="padding: 6px 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;">
|
<div style="padding: 6px 8px; background: #ffffff; border: 1px solid #d8e2ea; border-radius: 5px; font-size: 0.9em;">
|
||||||
<strong>${escapeHtml(pack.label)}</strong>
|
<strong>${escapeHtml(packLabel(pack))}</strong>
|
||||||
<span style="color: #4b5563;"> (${formatDisplayNumber(pack.pack_size_in_base_units)} ${escapeHtml(variant.unit)})</span>
|
<span style="color: #4b5563;"> (${formatDisplayNumber(pack.pack_size_in_base_units)} ${escapeHtml(variant.unit)})</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('')
|
`).join('')
|
||||||
@@ -882,9 +959,9 @@ function renderVariantInventoryDetails(variant) {
|
|||||||
? batches.map(batch => {
|
? batches.map(batch => {
|
||||||
const locationLabel = getBatchLocationLabel(batch);
|
const locationLabel = getBatchLocationLabel(batch);
|
||||||
const expired = isBatchExpired(batch);
|
const expired = isBatchExpired(batch);
|
||||||
const hasPackState = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label;
|
const hasPackState = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name;
|
||||||
const stocktakeLabel = hasPackState
|
const stocktakeLabel = hasPackState
|
||||||
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(variant.unit)} loose`
|
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(variant.unit)} loose`
|
||||||
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(variant.unit)}`;
|
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(variant.unit)}`;
|
||||||
const batchCardStyles = expired
|
const batchCardStyles = expired
|
||||||
? 'padding: 8px; background: #fff1f2; border: 1px solid #f3a6ad; border-radius: 5px; font-size: 0.9em;'
|
? 'padding: 8px; background: #fff1f2; border: 1px solid #f3a6ad; border-radius: 5px; font-size: 0.9em;'
|
||||||
@@ -1043,8 +1120,8 @@ function batchMatchesSelectedPack(batch, selectedPack) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const batchPackLabel = String(batch.received_pack_label || '').trim().toLowerCase();
|
const batchPackLabel = String(batch.received_pack_unit_name || '').trim().toLowerCase();
|
||||||
const selectedPackLabel = String(selectedPack.label || '').trim().toLowerCase();
|
const selectedPackLabel = String(selectedPack.pack_unit_name || '').trim().toLowerCase();
|
||||||
if (batchPackLabel && selectedPackLabel && batchPackLabel === selectedPackLabel) {
|
if (batchPackLabel && selectedPackLabel && batchPackLabel === selectedPackLabel) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1152,8 +1229,8 @@ function renderDispenseBatchAllocationRows(activeBatches) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 0.9em; color: #374151;">
|
<div style="font-size: 0.9em; color: #374151;">
|
||||||
${batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label
|
${batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name
|
||||||
? `Stock: ${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
|
? `Stock: ${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
|
||||||
: ''}
|
: ''}
|
||||||
${batchAvailabilityNote ? `<div style="margin-top: 4px; color: #d32f2f;">${batchAvailabilityNote}</div>` : ''}
|
${batchAvailabilityNote ? `<div style="margin-top: 4px; color: #d32f2f;">${batchAvailabilityNote}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -1204,8 +1281,8 @@ function renderExpiredDispenseBatches(expiredBatches) {
|
|||||||
expiredContent.innerHTML = expiredBatches.map(batch => {
|
expiredContent.innerHTML = expiredBatches.map(batch => {
|
||||||
const locationLabel = getBatchLocationLabel(batch);
|
const locationLabel = getBatchLocationLabel(batch);
|
||||||
const expiryLabel = formatDisplayDate(batch.expiry_date);
|
const expiryLabel = formatDisplayDate(batch.expiry_date);
|
||||||
const stocktakeLabel = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_label
|
const stocktakeLabel = batch.current_full_pack_count != null && batch.current_loose_base_units != null && batch.received_pack_unit_name
|
||||||
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(batch.received_pack_label)} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
|
? `${formatDisplayNumber(batch.current_full_pack_count)} full ${escapeHtml(packLabel(batch.received_pack_unit_name, batch.received_pack_size_snapshot))} + ${formatDisplayNumber(batch.current_loose_base_units)} ${escapeHtml(unitLabel)} loose`
|
||||||
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`;
|
: `${formatDisplayNumber(batch.quantity)} ${escapeHtml(unitLabel)}`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -1608,7 +1685,6 @@ function renderDrugs() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="drug-actions">
|
<div class="drug-actions">
|
||||||
${!isReadOnly ? `
|
${!isReadOnly ? `
|
||||||
<button class="btn btn-success btn-small" onclick="event.stopPropagation(); openReceiveDeliveryModal(${drug.id})">📦 Receive Delivery</button>
|
|
||||||
<button class="btn btn-primary btn-small" onclick="event.stopPropagation(); openAddVariantModal(${drug.id})">➕ Add</button>
|
<button class="btn btn-primary btn-small" onclick="event.stopPropagation(); openAddVariantModal(${drug.id})">➕ Add</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
<button class="btn btn-info btn-small" onclick="event.stopPropagation(); showDrugHistory(${drug.id})">📋 History</button>
|
<button class="btn btn-info btn-small" onclick="event.stopPropagation(); showDrugHistory(${drug.id})">📋 History</button>
|
||||||
@@ -2987,7 +3063,7 @@ function buildDeliveryPackOptions(variant, selectedPackId = '') {
|
|||||||
|
|
||||||
return [`<option value="">-- Select pack --</option>`, ...packs.map(pack => {
|
return [`<option value="">-- Select pack --</option>`, ...packs.map(pack => {
|
||||||
const selected = String(pack.id) === String(selectedPackId) ? ' selected' : '';
|
const selected = String(pack.id) === String(selectedPackId) ? ' selected' : '';
|
||||||
const label = `${pack.label} (${pack.pack_size_in_base_units} ${variant.unit})`;
|
const label = `${packLabel(pack)} (${pack.pack_size_in_base_units} ${variant.unit})`;
|
||||||
return `<option value="${pack.id}"${selected}>${escapeHtml(label)}</option>`;
|
return `<option value="${pack.id}"${selected}>${escapeHtml(label)}</option>`;
|
||||||
})].join('');
|
})].join('');
|
||||||
}
|
}
|
||||||
@@ -3014,10 +3090,22 @@ function updateDeliveryLineQuantityDisplay(line) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wireDeliveryLineEvents(line) {
|
function wireDeliveryLineEvents(line) {
|
||||||
|
const drugSelect = line.querySelector('.delivery-drug-select');
|
||||||
const variantSelect = line.querySelector('.delivery-variant-select');
|
const variantSelect = line.querySelector('.delivery-variant-select');
|
||||||
const packSelect = line.querySelector('.delivery-pack-select');
|
const packSelect = line.querySelector('.delivery-pack-select');
|
||||||
const packCountInput = line.querySelector('.delivery-pack-count');
|
const packCountInput = line.querySelector('.delivery-pack-count');
|
||||||
|
|
||||||
|
if (drugSelect && variantSelect) {
|
||||||
|
drugSelect.addEventListener('change', () => {
|
||||||
|
const drugId = parseInt(drugSelect.value || '', 10);
|
||||||
|
const drug = allDrugs.find(d => d.id === drugId) || null;
|
||||||
|
variantSelect.innerHTML = buildDeliveryVariantOptions(drug, '');
|
||||||
|
if (packSelect) packSelect.innerHTML = buildDeliveryPackOptions(null, '');
|
||||||
|
if (packCountInput) packCountInput.value = '';
|
||||||
|
updateDeliveryLineQuantityDisplay(line);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (variantSelect && packSelect) {
|
if (variantSelect && packSelect) {
|
||||||
variantSelect.addEventListener('change', () => {
|
variantSelect.addEventListener('change', () => {
|
||||||
const variantId = parseInt(variantSelect.value || '', 10);
|
const variantId = parseInt(variantSelect.value || '', 10);
|
||||||
@@ -3043,10 +3131,507 @@ function wireDeliveryLineEvents(line) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── GS1 barcode scanning ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a GS1-128 / DataMatrix scan string.
|
||||||
|
* Handles fixed-length AIs: 01 (GTIN-14), 17 (expiry YYMMDD), then 10 (lot).
|
||||||
|
* Returns { gtin, expiry (Date), lot } or null if the string doesn't match.
|
||||||
|
*/
|
||||||
|
// GS1 AI fixed-length lookup (number of data digits after the AI prefix).
|
||||||
|
// AIs not listed here are treated as variable-length (terminated by GS/FNC1 or end of string).
|
||||||
|
const GS1_FIXED_LENGTHS = {
|
||||||
|
'00': 18, '01': 14, '02': 14,
|
||||||
|
'11': 6, '12': 6, '13': 6, '14': 6, '15': 6, '16': 6, '17': 6, '18': 6, '19': 6,
|
||||||
|
'20': 2,
|
||||||
|
'31': 6, '32': 6, '33': 6, '34': 6, '35': 6, '36': 6,
|
||||||
|
'41': 13,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2-digit AI prefixes we recognise enough to skip over.
|
||||||
|
const GS1_KNOWN_AI_PREFIXES = new Set([
|
||||||
|
'00','01','02','10','11','12','13','14','15','16','17','18','19',
|
||||||
|
'20','21','22','23','24','25','26',
|
||||||
|
'30','31','32','33','34','35','36','37',
|
||||||
|
'40','41','42','43',
|
||||||
|
'70','71','72','73','74','75','76','77','78','79',
|
||||||
|
'80','81','82','83','84','85','86','87','88','89',
|
||||||
|
'90','91','92','93','94','95','96','97','98','99',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a GS1-128 / DataMatrix scan string.
|
||||||
|
* Fixed-length AIs are consumed exactly. Variable-length AIs are terminated
|
||||||
|
* by a GS (FNC1, \x1d) character — if no GS is present they run to end of string
|
||||||
|
* (per GS1 spec: variable-length fields must be FNC1-terminated unless last).
|
||||||
|
* Returns { gtin, expiry (Date), lot } or null if required fields not found.
|
||||||
|
*/
|
||||||
|
function parseGS1(raw) {
|
||||||
|
if (!raw || raw.length < 16) return null;
|
||||||
|
|
||||||
|
// Strip any leading AIM symbology identifier e.g. "]d2", "]Q3"
|
||||||
|
const aimPrefix = raw.match(/^\][a-zA-Z]\d/);
|
||||||
|
let data = aimPrefix ? raw.substring(3) : raw;
|
||||||
|
|
||||||
|
const GS = '\x1d'; // FNC1 separator
|
||||||
|
const hasGS = data.includes(GS);
|
||||||
|
|
||||||
|
let pos = 0;
|
||||||
|
let gtin = null, expiry = null, lot = null;
|
||||||
|
|
||||||
|
while (pos < data.length) {
|
||||||
|
if (data[pos] === GS) { pos++; continue; }
|
||||||
|
if (pos + 2 > data.length) break;
|
||||||
|
|
||||||
|
const ai = data.substring(pos, pos + 2);
|
||||||
|
if (!GS1_KNOWN_AI_PREFIXES.has(ai)) break;
|
||||||
|
|
||||||
|
pos += 2; // consume AI
|
||||||
|
|
||||||
|
if (GS1_FIXED_LENGTHS[ai] !== undefined) {
|
||||||
|
// Fixed-length: consume exactly N chars
|
||||||
|
const len = GS1_FIXED_LENGTHS[ai];
|
||||||
|
const value = data.substring(pos, pos + len);
|
||||||
|
pos += len;
|
||||||
|
|
||||||
|
if (ai === '01') {
|
||||||
|
if (value.length === 14 && /^\d{14}$/.test(value)) gtin = value;
|
||||||
|
} else if (ai === '17') {
|
||||||
|
const yy = parseInt(value.substring(0, 2), 10);
|
||||||
|
const mm = parseInt(value.substring(2, 4), 10);
|
||||||
|
const dd = parseInt(value.substring(4, 6), 10);
|
||||||
|
expiry = dd === 0
|
||||||
|
? new Date(yy + 2000, mm, 0) // last day of month
|
||||||
|
: new Date(yy + 2000, mm - 1, dd);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Variable-length: terminated by GS if present, otherwise end of string
|
||||||
|
let end;
|
||||||
|
if (hasGS) {
|
||||||
|
const gsIdx = data.indexOf(GS, pos);
|
||||||
|
end = gsIdx !== -1 ? gsIdx : data.length;
|
||||||
|
} else {
|
||||||
|
end = data.length;
|
||||||
|
}
|
||||||
|
const value = data.substring(pos, end);
|
||||||
|
pos = end;
|
||||||
|
|
||||||
|
if (ai === '10') lot = value;
|
||||||
|
// ai 21 (serial), 22, etc. ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gtin || !expiry || !lot) return null;
|
||||||
|
return { gtin, expiry, lot };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a Date as YYYY-MM-DD for use in <input type="date"> */
|
||||||
|
function formatDateForInput(d) {
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${yyyy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer for keyboard-wedge barcode detection
|
||||||
|
let _scanBuffer = [];
|
||||||
|
let _scanBufferTimer = null;
|
||||||
|
let _activeScanLineEl = null; // last delivery line that received focus
|
||||||
|
let _preScanFocusedInput = null; // input that had focus when scan started
|
||||||
|
let _preScanFocusedValue = null; // its value before any scan chars were typed
|
||||||
|
const SCAN_MAX_GAP_MS = 50;
|
||||||
|
const SCAN_MIN_LENGTH = 20;
|
||||||
|
|
||||||
|
function _onDeliveryModalKeydown(e) {
|
||||||
|
// Only act when the receive delivery modal is open
|
||||||
|
if (!document.getElementById('receiveDeliveryModal')?.classList.contains('show')) return;
|
||||||
|
|
||||||
|
// Track which delivery line last had focus
|
||||||
|
const focusedLine = document.activeElement?.closest('.delivery-line');
|
||||||
|
if (focusedLine) _activeScanLineEl = focusedLine;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const raw = _scanBuffer.map(x => x.char).join('');
|
||||||
|
console.log('[barcode] Enter received. Buffer length:', raw.length, 'Content:', raw);
|
||||||
|
_scanBuffer = [];
|
||||||
|
if (_scanBufferTimer) { clearTimeout(_scanBufferTimer); _scanBufferTimer = null; }
|
||||||
|
|
||||||
|
// Only treat as a scan if it arrived very fast
|
||||||
|
if (raw.length >= SCAN_MIN_LENGTH) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Restore the focused input to its pre-scan value (remove the 1 char that slipped in)
|
||||||
|
if (_preScanFocusedInput) {
|
||||||
|
_preScanFocusedInput.value = _preScanFocusedValue || '';
|
||||||
|
}
|
||||||
|
_preScanFocusedInput = null;
|
||||||
|
_preScanFocusedValue = null;
|
||||||
|
console.log('[barcode] Treating as scan, calling handleBarcodeScan');
|
||||||
|
handleBarcodeScan(raw);
|
||||||
|
} else {
|
||||||
|
console.log('[barcode] Buffer too short for scan, ignoring');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single printable character
|
||||||
|
if (e.key.length === 1) {
|
||||||
|
const gap = _scanBuffer.length > 0 ? now - _scanBuffer[_scanBuffer.length - 1].time : 0;
|
||||||
|
|
||||||
|
// If gap is too large, start fresh (human typed slowly)
|
||||||
|
if (_scanBuffer.length > 0 && gap > SCAN_MAX_GAP_MS) {
|
||||||
|
console.log('[barcode] Gap too large (' + gap + 'ms), resetting buffer');
|
||||||
|
_scanBuffer = [];
|
||||||
|
_preScanFocusedInput = null;
|
||||||
|
_preScanFocusedValue = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the focused input + its value before the first scan char lands
|
||||||
|
if (_scanBuffer.length === 0) {
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
|
||||||
|
_preScanFocusedInput = active;
|
||||||
|
_preScanFocusedValue = active.value;
|
||||||
|
} else {
|
||||||
|
_preScanFocusedInput = null;
|
||||||
|
_preScanFocusedValue = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Subsequent rapid chars — suppress them from going into the focused input
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
_scanBuffer.push({ char: e.key, time: now });
|
||||||
|
console.log('[barcode] Buffered char:', e.key, '| gap:', gap + 'ms | buffer length:', _scanBuffer.length);
|
||||||
|
|
||||||
|
// Auto-clear buffer if Enter never comes
|
||||||
|
if (_scanBufferTimer) clearTimeout(_scanBufferTimer);
|
||||||
|
_scanBufferTimer = setTimeout(() => {
|
||||||
|
console.log('[barcode] Buffer auto-cleared (no Enter)');
|
||||||
|
_scanBuffer = [];
|
||||||
|
_preScanFocusedInput = null;
|
||||||
|
_preScanFocusedValue = null;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBarcodeScan(raw) {
|
||||||
|
const parsed = parseGS1(raw);
|
||||||
|
if (!parsed) {
|
||||||
|
showToast('Barcode not recognised as a GS1 code', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { gtin, expiry, lot } = parsed;
|
||||||
|
const expiryStr = formatDateForInput(expiry);
|
||||||
|
|
||||||
|
// Look up GTIN mapping
|
||||||
|
let mapping = null;
|
||||||
|
try {
|
||||||
|
const resp = await apiCall(`/gtin/${encodeURIComponent(gtin)}`);
|
||||||
|
if (resp.ok) {
|
||||||
|
mapping = await resp.json();
|
||||||
|
} else if (resp.status !== 404) {
|
||||||
|
throw new Error(`Server error ${resp.status}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Failed to look up barcode: ' + err.message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapping) {
|
||||||
|
// Unknown GTIN — open mapping modal then re-process
|
||||||
|
openGtinMappingModal(gtin, expiryStr, lot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyBarcodeScanToLines(mapping, lot, expiryStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _applyBarcodeScanToLines(mapping, lot, expiryStr) {
|
||||||
|
const container = document.getElementById('deliveryLinesContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const lines = Array.from(container.querySelectorAll('.delivery-line'));
|
||||||
|
|
||||||
|
// 1. Find an existing line with the same variant + lot + expiry → increment pack count
|
||||||
|
for (const line of lines) {
|
||||||
|
const variantId = line.querySelector('.delivery-variant-select')?.value;
|
||||||
|
const batchVal = line.querySelector('.delivery-batch-number')?.value?.trim();
|
||||||
|
const expiryVal = line.querySelector('.delivery-expiry-date')?.value;
|
||||||
|
|
||||||
|
if (
|
||||||
|
String(variantId) === String(mapping.drug_variant_id) &&
|
||||||
|
batchVal === lot &&
|
||||||
|
expiryVal === expiryStr
|
||||||
|
) {
|
||||||
|
const countInput = line.querySelector('.delivery-pack-count');
|
||||||
|
const current = parseFloat(countInput.value) || 0;
|
||||||
|
countInput.value = current + 1;
|
||||||
|
updateDeliveryLineQuantityDisplay(line);
|
||||||
|
showToast(`Pack count incremented to ${current + 1} for lot ${lot}`, 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Find any existing empty line (lot and expiry both blank) — never overwrite a filled line
|
||||||
|
const emptyLine = lines.find(l => {
|
||||||
|
const batch = l.querySelector('.delivery-batch-number')?.value?.trim();
|
||||||
|
const expiry = l.querySelector('.delivery-expiry-date')?.value;
|
||||||
|
return !batch && !expiry;
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
if (emptyLine) {
|
||||||
|
_populateDeliveryLine(emptyLine, mapping, lot, expiryStr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Append a new line
|
||||||
|
appendDeliveryLine();
|
||||||
|
const newLine = container.querySelector('.delivery-line:last-child');
|
||||||
|
if (newLine) _populateDeliveryLine(newLine, mapping, lot, expiryStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _populateDeliveryLine(line, mapping, lot, expiryStr) {
|
||||||
|
const drugSelect = line.querySelector('.delivery-drug-select');
|
||||||
|
const variantSelect = line.querySelector('.delivery-variant-select');
|
||||||
|
const packSelect = line.querySelector('.delivery-pack-select');
|
||||||
|
const batchInput = line.querySelector('.delivery-batch-number');
|
||||||
|
const expiryInput = line.querySelector('.delivery-expiry-date');
|
||||||
|
const packCountInput = line.querySelector('.delivery-pack-count');
|
||||||
|
|
||||||
|
if (drugSelect) {
|
||||||
|
drugSelect.innerHTML = buildDeliveryDrugOptions(mapping.drug_id);
|
||||||
|
drugSelect.value = String(mapping.drug_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variantSelect) {
|
||||||
|
const drug = allDrugs.find(d => d.id === mapping.drug_id) || null;
|
||||||
|
variantSelect.innerHTML = buildDeliveryVariantOptions(drug, mapping.drug_variant_id);
|
||||||
|
variantSelect.value = String(mapping.drug_variant_id);
|
||||||
|
const variant = getVariantById(mapping.drug_variant_id);
|
||||||
|
if (packSelect) {
|
||||||
|
packSelect.innerHTML = buildDeliveryPackOptions(variant, mapping.variant_pack_id);
|
||||||
|
packSelect.value = String(mapping.variant_pack_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batchInput) batchInput.value = lot;
|
||||||
|
if (expiryInput) expiryInput.value = expiryStr;
|
||||||
|
if (packCountInput && !packCountInput.value) packCountInput.value = 1;
|
||||||
|
|
||||||
|
updateDeliveryLineQuantityDisplay(line);
|
||||||
|
showToast(`Populated: ${mapping.drug_name} ${mapping.variant_strength} — lot ${lot}`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _detachDeliveryBarcodeListener() {
|
||||||
|
const modalEl = document.getElementById('receiveDeliveryModal');
|
||||||
|
if (modalEl?._barcodeListener) {
|
||||||
|
document.removeEventListener('keydown', modalEl._barcodeListener);
|
||||||
|
modalEl._barcodeListener = null;
|
||||||
|
}
|
||||||
|
_scanBuffer = [];
|
||||||
|
_activeScanLineEl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _refreshGtinMappingSelects() {
|
||||||
|
// Kept for compatibility — delegates to reinit with current selections
|
||||||
|
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect')?.value || '', 10) || null;
|
||||||
|
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect')?.value || '', 10) || null;
|
||||||
|
const packId = parseInt(document.getElementById('gtinMappingPackSelect')?.value || '', 10) || null;
|
||||||
|
_reinitGtinMappingModal({ drugId, variantId, packId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reinitialise the GTIN mapping modal dropdowns from fresh allDrugs data,
|
||||||
|
// optionally pre-selecting specific drug/variant/pack IDs.
|
||||||
|
function _reinitGtinMappingModal(restore) {
|
||||||
|
const drugSelect = document.getElementById('gtinMappingDrugSelect');
|
||||||
|
const variantSelect = document.getElementById('gtinMappingVariantSelect');
|
||||||
|
const packSelect = document.getElementById('gtinMappingPackSelect');
|
||||||
|
if (!drugSelect) return;
|
||||||
|
|
||||||
|
// Rebuild drug list
|
||||||
|
drugSelect.innerHTML = '<option value="">-- Select drug --</option>' +
|
||||||
|
allDrugs.map(d => `<option value="${d.id}">${escapeHtml(d.name)}</option>`).join('');
|
||||||
|
|
||||||
|
const drugId = restore?.drugId || null;
|
||||||
|
const variantId = restore?.variantId || null;
|
||||||
|
const packId = restore?.packId || null;
|
||||||
|
|
||||||
|
// If no drug to restore, clear cascades and stop
|
||||||
|
if (!drugId) {
|
||||||
|
variantSelect.innerHTML = '<option value="">-- Select variant --</option>';
|
||||||
|
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
drugSelect.value = String(drugId);
|
||||||
|
const drug = allDrugs.find(d => d.id === drugId);
|
||||||
|
|
||||||
|
// Rebuild variant list
|
||||||
|
variantSelect.innerHTML = '<option value="">-- Select variant --</option>';
|
||||||
|
if (drug) {
|
||||||
|
variantSelect.innerHTML += drug.variants.map(v =>
|
||||||
|
`<option value="${v.id}">${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!variantId) {
|
||||||
|
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
variantSelect.value = String(variantId);
|
||||||
|
const variant = drug?.variants?.find(v => v.id === variantId);
|
||||||
|
|
||||||
|
// Rebuild pack list
|
||||||
|
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||||||
|
if (variant) {
|
||||||
|
const packs = getActivePacksForVariant(variant);
|
||||||
|
packSelect.innerHTML += packs.map(p =>
|
||||||
|
`<option value="${p.id}">${escapeHtml(packLabel(p))}</option>`
|
||||||
|
).join('');
|
||||||
|
if (packId) packSelect.value = String(packId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gtinMappingAddDrug() {
|
||||||
|
// Snapshot current drug IDs. handleAddDrug will call loadDrugs() once (we intercept it
|
||||||
|
// to find the new drug ID), then open addVariantModal. We hook the *subsequent* loadDrugs
|
||||||
|
// call (from handleAddVariant) to reinit the GTIN modal with drug+variant selected.
|
||||||
|
_gtinMappingWaitingForNewDrug = new Set(allDrugs.map(d => d.id));
|
||||||
|
openModal(document.getElementById('addModal'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function gtinMappingAddVariant() {
|
||||||
|
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
|
||||||
|
if (!drugId) { showToast('Select a drug first', 'warning'); return; }
|
||||||
|
const drug = allDrugs.find(d => d.id === drugId);
|
||||||
|
const existingVariantIds = new Set((drug?.variants || []).map(v => v.id));
|
||||||
|
// After reload, find the new variant by diffing
|
||||||
|
_gtinMappingPendingRestore = { drugId, variantId: null, packId: null, _existingVariantIds: existingVariantIds };
|
||||||
|
_gtinMappingPendingRefresh = true;
|
||||||
|
openAddVariantModal(drugId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gtinMappingAddPack() {
|
||||||
|
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10);
|
||||||
|
if (!variantId) { showToast('Select a variant first', 'warning'); return; }
|
||||||
|
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
|
||||||
|
const drug = allDrugs.find(d => d.id === drugId);
|
||||||
|
if (!drug) return;
|
||||||
|
const variant = drug.variants?.find(v => v.id === variantId);
|
||||||
|
const existingPackIds = new Set((getActivePacksForVariant(variant) || []).map(p => p.id));
|
||||||
|
_gtinMappingPendingRestore = { drugId, variantId, packId: null, _existingPackIds: existingPackIds };
|
||||||
|
_gtinMappingPendingRefresh = true;
|
||||||
|
deliveryDrugId = drugId;
|
||||||
|
_gtinMappingPendingVariantId = variantId;
|
||||||
|
openAddPackSizeFromDeliveryModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GTIN mapping modal logic ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _pendingGtinScan = null; // { gtin, expiryStr, lot } while mapping modal is open
|
||||||
|
|
||||||
|
function openGtinMappingModal(gtin, expiryStr, lot) {
|
||||||
|
_pendingGtinScan = { gtin, expiryStr, lot };
|
||||||
|
|
||||||
|
document.getElementById('gtinMappingGtin').value = gtin;
|
||||||
|
|
||||||
|
// Populate drug selector from allDrugs
|
||||||
|
const drugSelect = document.getElementById('gtinMappingDrugSelect');
|
||||||
|
drugSelect.innerHTML = '<option value="">-- Select drug --</option>' +
|
||||||
|
allDrugs.map(d => `<option value="${d.id}">${escapeHtml(d.name)}</option>`).join('');
|
||||||
|
|
||||||
|
document.getElementById('gtinMappingVariantSelect').innerHTML = '<option value="">-- Select variant --</option>';
|
||||||
|
document.getElementById('gtinMappingPackSelect').innerHTML = '<option value="">-- Select pack --</option>';
|
||||||
|
|
||||||
|
openModal(document.getElementById('gtinMappingModal'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGtinMappingDrugChange() {
|
||||||
|
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
|
||||||
|
const drug = allDrugs.find(d => d.id === drugId);
|
||||||
|
const variantSelect = document.getElementById('gtinMappingVariantSelect');
|
||||||
|
const packSelect = document.getElementById('gtinMappingPackSelect');
|
||||||
|
|
||||||
|
variantSelect.innerHTML = '<option value="">-- Select variant --</option>';
|
||||||
|
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||||||
|
|
||||||
|
if (!drug) return;
|
||||||
|
variantSelect.innerHTML += drug.variants.map(v =>
|
||||||
|
`<option value="${v.id}">${escapeHtml(v.strength)} (${escapeHtml(v.unit)})</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGtinMappingVariantChange() {
|
||||||
|
const drugId = parseInt(document.getElementById('gtinMappingDrugSelect').value || '', 10);
|
||||||
|
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10);
|
||||||
|
const drug = allDrugs.find(d => d.id === drugId);
|
||||||
|
const variant = drug?.variants?.find(v => v.id === variantId);
|
||||||
|
const packSelect = document.getElementById('gtinMappingPackSelect');
|
||||||
|
|
||||||
|
packSelect.innerHTML = '<option value="">-- Select pack --</option>';
|
||||||
|
if (!variant) return;
|
||||||
|
|
||||||
|
const packs = getActivePacksForVariant(variant);
|
||||||
|
packSelect.innerHTML += packs.map(p =>
|
||||||
|
`<option value="${p.id}">${escapeHtml(packLabel(p))}</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveGtinMapping() {
|
||||||
|
if (!_pendingGtinScan) return;
|
||||||
|
|
||||||
|
const variantId = parseInt(document.getElementById('gtinMappingVariantSelect').value || '', 10);
|
||||||
|
const packId = parseInt(document.getElementById('gtinMappingPackSelect').value || '', 10);
|
||||||
|
|
||||||
|
if (!variantId || !packId) {
|
||||||
|
showToast('Please select a variant and pack', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await apiCall('/gtin', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
gtin: _pendingGtinScan.gtin,
|
||||||
|
drug_variant_id: variantId,
|
||||||
|
variant_pack_id: packId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
throw new Error(err.detail || 'Failed to save GTIN mapping');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = await resp.json();
|
||||||
|
closeModal(document.getElementById('gtinMappingModal'));
|
||||||
|
showToast(`GTIN mapped to ${mapping.drug_name} ${mapping.variant_strength}`, 'success');
|
||||||
|
|
||||||
|
// Now apply the scan that triggered this
|
||||||
|
_applyBarcodeScanToLines(mapping, _pendingGtinScan.lot, _pendingGtinScan.expiryStr);
|
||||||
|
_pendingGtinScan = null;
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Error saving GTIN: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeliveryDrugOptions(selectedDrugId = '') {
|
||||||
|
return [
|
||||||
|
'<option value="">-- Select drug --</option>',
|
||||||
|
...allDrugs.map(d => {
|
||||||
|
const sel = String(d.id) === String(selectedDrugId) ? ' selected' : '';
|
||||||
|
return `<option value="${d.id}"${sel}>${escapeHtml(d.name)}</option>`;
|
||||||
|
})
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
function appendDeliveryLine(prefill = {}) {
|
function appendDeliveryLine(prefill = {}) {
|
||||||
const container = document.getElementById('deliveryLinesContainer');
|
const container = document.getElementById('deliveryLinesContainer');
|
||||||
const drug = getActiveDeliveryDrug();
|
if (!container) return;
|
||||||
if (!container || !drug) return;
|
|
||||||
|
|
||||||
deliveryLineCounter += 1;
|
deliveryLineCounter += 1;
|
||||||
const lineId = `delivery-line-${deliveryLineCounter}`;
|
const lineId = `delivery-line-${deliveryLineCounter}`;
|
||||||
@@ -3055,19 +3640,27 @@ function appendDeliveryLine(prefill = {}) {
|
|||||||
line.className = 'delivery-line';
|
line.className = 'delivery-line';
|
||||||
line.dataset.lineId = lineId;
|
line.dataset.lineId = lineId;
|
||||||
|
|
||||||
const initialVariant = prefill.variantId
|
const initialDrug = prefill.drugId ? allDrugs.find(d => String(d.id) === String(prefill.drugId)) || null : null;
|
||||||
? drug.variants.find(v => String(v.id) === String(prefill.variantId)) || null
|
const initialDrugId = prefill.drugId || '';
|
||||||
: drug.variants.length === 1 ? drug.variants[0] : null;
|
const initialVariant = prefill.variantId && initialDrug
|
||||||
const initialVariantId = prefill.variantId || (initialVariant ? initialVariant.id : '');
|
? initialDrug.variants.find(v => String(v.id) === String(prefill.variantId)) || null
|
||||||
const initialPackId = prefill.packId || (getActivePacksForVariant(initialVariant).length === 1 ? getActivePacksForVariant(initialVariant)[0].id : '');
|
: null;
|
||||||
|
const initialVariantId = prefill.variantId || '';
|
||||||
|
const initialPackId = prefill.packId || (initialVariant && getActivePacksForVariant(initialVariant).length === 1 ? getActivePacksForVariant(initialVariant)[0].id : '');
|
||||||
const initialPackCount = prefill.packCount || '';
|
const initialPackCount = prefill.packCount || '';
|
||||||
|
|
||||||
line.innerHTML = `
|
line.innerHTML = `
|
||||||
<div class="delivery-line-grid">
|
<div class="delivery-line-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Drug</label>
|
||||||
|
<select class="delivery-drug-select" required>
|
||||||
|
${buildDeliveryDrugOptions(initialDrugId)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Variant</label>
|
<label>Variant</label>
|
||||||
<select class="delivery-variant-select" required>
|
<select class="delivery-variant-select" required>
|
||||||
${buildDeliveryVariantOptions(drug, initialVariantId)}
|
${buildDeliveryVariantOptions(initialDrug, initialVariantId)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -3116,25 +3709,24 @@ function appendDeliveryLine(prefill = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshDeliveryVariantSelects() {
|
function refreshDeliveryVariantSelects() {
|
||||||
const drug = getActiveDeliveryDrug();
|
|
||||||
const container = document.getElementById('deliveryLinesContainer');
|
const container = document.getElementById('deliveryLinesContainer');
|
||||||
if (!drug || !container) return;
|
if (!container) return;
|
||||||
|
|
||||||
container.querySelectorAll('.delivery-line').forEach(line => {
|
container.querySelectorAll('.delivery-line').forEach(line => {
|
||||||
const select = line.querySelector('.delivery-variant-select');
|
const drugSelect = line.querySelector('.delivery-drug-select');
|
||||||
|
const variantSelect = line.querySelector('.delivery-variant-select');
|
||||||
const packSelect = line.querySelector('.delivery-pack-select');
|
const packSelect = line.querySelector('.delivery-pack-select');
|
||||||
if (!select) return;
|
if (!variantSelect) return;
|
||||||
|
|
||||||
const currentVariantId = select.value;
|
const drugId = parseInt(drugSelect?.value || '', 10);
|
||||||
const nextVariantId = currentVariantId || (drug.variants.length === 1 ? String(drug.variants[0].id) : '');
|
const drug = allDrugs.find(d => d.id === drugId) || null;
|
||||||
select.innerHTML = buildDeliveryVariantOptions(drug, nextVariantId);
|
const currentVariantId = variantSelect.value;
|
||||||
|
variantSelect.innerHTML = buildDeliveryVariantOptions(drug, currentVariantId);
|
||||||
|
|
||||||
const variant = getVariantById(parseInt(select.value || '', 10));
|
const variant = getVariantById(parseInt(variantSelect.value || '', 10));
|
||||||
if (packSelect) {
|
if (packSelect) {
|
||||||
const currentPackId = packSelect.value;
|
const currentPackId = packSelect.value;
|
||||||
const activePacks = getActivePacksForVariant(variant);
|
packSelect.innerHTML = buildDeliveryPackOptions(variant, currentPackId);
|
||||||
const nextPackId = currentPackId || (activePacks.length === 1 ? String(activePacks[0].id) : '');
|
|
||||||
packSelect.innerHTML = buildDeliveryPackOptions(variant, nextPackId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDeliveryLineQuantityDisplay(line);
|
updateDeliveryLineQuantityDisplay(line);
|
||||||
@@ -3154,13 +3746,8 @@ async function initializeDeliveryLocations() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openReceiveDeliveryModal(drugId) {
|
async function openReceiveDeliveryModal() {
|
||||||
deliveryDrugId = drugId;
|
deliveryDrugId = null;
|
||||||
const drug = getActiveDeliveryDrug();
|
|
||||||
if (!drug) {
|
|
||||||
showToast('Drug not found', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = document.getElementById('receiveDeliveryForm');
|
const form = document.getElementById('receiveDeliveryForm');
|
||||||
const container = document.getElementById('deliveryLinesContainer');
|
const container = document.getElementById('deliveryLinesContainer');
|
||||||
@@ -3168,17 +3755,33 @@ async function openReceiveDeliveryModal(drugId) {
|
|||||||
|
|
||||||
if (form) form.reset();
|
if (form) form.reset();
|
||||||
if (container) container.innerHTML = '';
|
if (container) container.innerHTML = '';
|
||||||
if (label) label.textContent = `Drug: ${drug.name}`;
|
if (label) label.textContent = 'Scan items or add lines manually';
|
||||||
|
|
||||||
await initializeDeliveryLocations();
|
await initializeDeliveryLocations();
|
||||||
appendDeliveryLine();
|
appendDeliveryLine();
|
||||||
|
|
||||||
openModal(document.getElementById('receiveDeliveryModal'));
|
// Attach barcode scanner listener
|
||||||
|
_activeScanLineEl = null;
|
||||||
|
_scanBuffer = [];
|
||||||
|
const modalEl = document.getElementById('receiveDeliveryModal');
|
||||||
|
if (modalEl._barcodeListener) document.removeEventListener('keydown', modalEl._barcodeListener);
|
||||||
|
modalEl._barcodeListener = _onDeliveryModalKeydown;
|
||||||
|
document.addEventListener('keydown', modalEl._barcodeListener);
|
||||||
|
console.log('[barcode] Listener attached to receiveDeliveryModal');
|
||||||
|
|
||||||
|
openModal(modalEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddVariantFromDelivery() {
|
function handleAddVariantFromDelivery() {
|
||||||
if (!deliveryDrugId) {
|
if (!deliveryDrugId) {
|
||||||
showToast('Select a drug first', 'warning');
|
const deliveryContainer = document.getElementById('deliveryLinesContainer');
|
||||||
|
const firstDrugIdStr = deliveryContainer
|
||||||
|
? Array.from(deliveryContainer.querySelectorAll('.delivery-drug-select')).map(s => s.value).find(v => v)
|
||||||
|
: null;
|
||||||
|
deliveryDrugId = firstDrugIdStr ? parseInt(firstDrugIdStr, 10) : null;
|
||||||
|
}
|
||||||
|
if (!deliveryDrugId) {
|
||||||
|
showToast('Select a drug on a delivery line first', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openAddVariantModal(deliveryDrugId);
|
openAddVariantModal(deliveryDrugId);
|
||||||
@@ -3186,10 +3789,19 @@ function handleAddVariantFromDelivery() {
|
|||||||
|
|
||||||
function openAddPackSizeFromDeliveryModal() {
|
function openAddPackSizeFromDeliveryModal() {
|
||||||
if (!deliveryDrugId) {
|
if (!deliveryDrugId) {
|
||||||
showToast('Select a drug first', 'warning');
|
// In multi-drug mode, get drug from the first line that has one selected
|
||||||
|
const deliveryContainer = document.getElementById('deliveryLinesContainer');
|
||||||
|
const firstDrugIdStr = deliveryContainer
|
||||||
|
? Array.from(deliveryContainer.querySelectorAll('.delivery-drug-select')).map(s => s.value).find(v => v)
|
||||||
|
: null;
|
||||||
|
deliveryDrugId = firstDrugIdStr ? parseInt(firstDrugIdStr, 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deliveryDrugId) {
|
||||||
|
showToast('Select a drug on a delivery line first', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const drug = getActiveDeliveryDrug();
|
const drug = allDrugs.find(d => d.id === deliveryDrugId);
|
||||||
if (!drug) {
|
if (!drug) {
|
||||||
showToast('Drug not found', 'error');
|
showToast('Drug not found', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -3243,7 +3855,6 @@ async function handleAddPackSize(e) {
|
|||||||
const variantId = parseInt(document.getElementById('addPackSizeVariantSelect')?.value || '', 10);
|
const variantId = parseInt(document.getElementById('addPackSizeVariantSelect')?.value || '', 10);
|
||||||
const packType = (document.getElementById('addPackSizeType')?.value || 'box').trim();
|
const packType = (document.getElementById('addPackSizeType')?.value || 'box').trim();
|
||||||
const packSize = parseFloat(document.getElementById('addPackSizeCount')?.value || '');
|
const packSize = parseFloat(document.getElementById('addPackSizeCount')?.value || '');
|
||||||
const packLabel = `${packType.charAt(0).toUpperCase() + packType.slice(1)} of ${packSize}`;
|
|
||||||
|
|
||||||
if (!variantId) {
|
if (!variantId) {
|
||||||
showToast('Please select a variant', 'warning');
|
showToast('Please select a variant', 'warning');
|
||||||
@@ -3258,7 +3869,6 @@ async function handleAddPackSize(e) {
|
|||||||
const response = await apiCall(`/variants/${variantId}/packs`, {
|
const response = await apiCall(`/variants/${variantId}/packs`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
label: packLabel,
|
|
||||||
pack_unit_name: packType,
|
pack_unit_name: packType,
|
||||||
pack_size_in_base_units: packSize,
|
pack_size_in_base_units: packSize,
|
||||||
is_active: true
|
is_active: true
|
||||||
@@ -3284,9 +3894,8 @@ async function handleAddPackSize(e) {
|
|||||||
async function handleReceiveDelivery(e) {
|
async function handleReceiveDelivery(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const drug = getActiveDeliveryDrug();
|
|
||||||
const container = document.getElementById('deliveryLinesContainer');
|
const container = document.getElementById('deliveryLinesContainer');
|
||||||
if (!drug || !container) {
|
if (!container) {
|
||||||
showToast('Delivery context unavailable', 'error');
|
showToast('Delivery context unavailable', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3320,7 +3929,7 @@ async function handleReceiveDelivery(e) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const variant = drug.variants.find(v => v.id === variantId);
|
const variant = getVariantById(variantId);
|
||||||
const selectedPack = variant ? getActivePacksForVariant(variant).find(pack => pack.id === packId) : null;
|
const selectedPack = variant ? getActivePacksForVariant(variant).find(pack => pack.id === packId) : null;
|
||||||
if (!selectedPack) {
|
if (!selectedPack) {
|
||||||
showToast(`Delivery line ${i + 1} has an invalid pack selection`, 'warning');
|
showToast(`Delivery line ${i + 1} has an invalid pack selection`, 'warning');
|
||||||
@@ -3356,6 +3965,7 @@ async function handleReceiveDelivery(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeModal(document.getElementById('receiveDeliveryModal'));
|
closeModal(document.getElementById('receiveDeliveryModal'));
|
||||||
|
_detachDeliveryBarcodeListener();
|
||||||
await loadDrugs();
|
await loadDrugs();
|
||||||
showToast(`Delivery received successfully (${payloads.length} line${payloads.length === 1 ? '' : 's'})`, 'success');
|
showToast(`Delivery received successfully (${payloads.length} line${payloads.length === 1 ? '' : 's'})`, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
<div class="inventory-toolbar">
|
<div class="inventory-toolbar">
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="printNotesBtn" class="btn btn-primary btn-small">📝 Print Notes</button>
|
<button id="printNotesBtn" class="btn btn-primary btn-small">📝 Print Notes</button>
|
||||||
|
<button id="receiveDeliveryBtn" class="btn btn-success btn-small">📦 Receive Delivery</button>
|
||||||
<button id="addDrugBtn" class="btn btn-primary btn-small">➕ Add Drug</button>
|
<button id="addDrugBtn" class="btn btn-primary btn-small">➕ Add Drug</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-search">
|
<div class="toolbar-search">
|
||||||
@@ -632,6 +633,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- GTIN Mapping Modal -->
|
||||||
|
<div id="gtinMappingModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<h2>Unknown Barcode — Map GTIN</h2>
|
||||||
|
<p style="color:#666; margin-bottom:16px;">This barcode hasn't been seen before. Please map it to the correct drug, variant and pack.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>GTIN</label>
|
||||||
|
<input type="text" id="gtinMappingGtin" readonly style="background:#f5f5f5;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Drug</label>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
|
<select id="gtinMappingDrugSelect" onchange="onGtinMappingDrugChange()" style="flex:1">
|
||||||
|
<option value="">-- Select drug --</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddDrug()">+ New Drug</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Variant</label>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
|
<select id="gtinMappingVariantSelect" onchange="onGtinMappingVariantChange()" style="flex:1">
|
||||||
|
<option value="">-- Select variant --</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddVariant()">+ New Variant</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Pack</label>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
|
<select id="gtinMappingPackSelect" style="flex:1">
|
||||||
|
<option value="">-- Select pack --</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddPack()">+ New Pack</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-primary" onclick="handleSaveGtinMapping()">Save Mapping</button>
|
||||||
|
<button type="button" class="btn btn-secondary close">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|||||||
+1
-1
@@ -913,7 +913,7 @@ footer {
|
|||||||
|
|
||||||
.delivery-line-grid {
|
.delivery-line-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1.9fr 1.8fr 0.9fr 1.4fr 1.2fr 1.3fr auto;
|
grid-template-columns: 1.9fr 1.8fr 1.5fr 0.8fr 1.2fr 1.3fr auto;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user