PharmaLogistics Inc. is planning the distribution network for its new temperature-sensitive drug, 'VitaBoost', for the upcoming 6 months (Month 1 to Month 6). The company has two manufacturing plants (P1 located in City A, P2 located in City B) and is considering opening and operating up to three potential distribution centers (DC1 in Region X, DC2 in Region Y, DC3 in Region Z) to serve four key demand regions (R1, R2, R3, R4). The primary objective is to minimize the total operational costs over the 6-month planning horizon. These costs include: the fixed monthly costs of operating any active DCs, transportation costs for shipping VitaBoost from plants to DCs and from DCs to demand regions, inventory holding costs for products stored at DCs, costs associated with product expiry and disposal, and shortage costs incurred for any unmet demand in the regions.

VitaBoost has a strict shelf life of 2 months. This means that any product manufactured in a given month (say, Month `m`) must be used to satisfy demand by the end of the following month (Month `m+1`). Product not used by this deadline expires and must be disposed of.

At the beginning of Month 1, DC1 has an initial stock of VitaBoost, provided it is chosen to be operational in Month 1: 10 units that were produced in the immediately preceding month (referred to as 'Month 0' batch, thus being 1-month old at the start of Month 1) and 5 units that were produced two months prior (referred to as 'Month -1' batch, thus being 2-months old at the start of Month 1). This 'Month -1' batch must be used or discarded during Month 1. No other initial inventory exists at other potential DCs or plants.

The monthly production capacities for the manufacturing plants are as follows (in units, for Months 1 through 6 respectively):
-   Plant P1: [250, 250, 260, 260, 270, 270]
-   Plant P2: [200, 200, 210, 210, 220, 220]

The potential distribution centers have associated costs and capacities:
-   Fixed monthly operating costs if active: DC1: $10,000; DC2: $12,000; DC3: $9,000.
-   Storage capacities (max units): DC1: 300; DC2: 350; DC3: 280.

Transportation costs per unit of VitaBoost are:
-   From Plant P1 to DCs: DC1: $5, DC2: $7, DC3: $6.
-   From Plant P2 to DCs: DC1: $8, DC2: $4, DC3: $7.
-   From DC1 to Regions: R1: $3, R2: $4, R3: $5, R4: $6.
-   From DC2 to Regions: R1: $5, R2: $3, R3: $4, R4: $5.
-   From DC3 to Regions: R1: $4, R2: $5, R3: $3, R4: $4.

Other relevant costs are:
-   Monthly inventory holding cost per unit at DCs: DC1: $2.0; DC2: $2.5; DC3: $1.8.
-   Disposal cost for expired product: $1 per unit at any DC.
-   Shortage cost for unmet demand: $50 per unit, uniform across all regions and months.

The forecasted monthly demand for VitaBoost (in units) for each region over the 6-month horizon is:
-   Region R1: [100, 110, 120, 130, 140, 150]
-   Region R2: [80, 85, 90, 95, 100, 105]
-   Region R3: [120, 125, 130, 135, 140, 145]
-   Region R4: [60, 65, 70, 75, 80, 85]

PharmaLogistics Inc. needs to determine an optimal plan that specifies: which DCs to operate in each month, the quantity of VitaBoost to produce at each plant monthly, and the detailed shipping and inventory quantities across the network. This plan must ensure that demand is met as effectively as possible while respecting the product's shelf life and minimizing total costs.

Problem type: MILP (Medium: 954 variables, 453 constraints, 2532 nonzeros)

Mathematical model:
Sets:
T: Set of time periods (months), ( T = {1, 2, 3, 4, 5, 6} ).
P: Set of manufacturing plants, ( P = {P1, P2} ).
D: Set of potential distribution centers, ( D = {DC1, DC2, DC3} ).
R: Set of demand regions, ( R = {R1, R2, R3, R4} ).
B: Set of batch production months, ( B = {-1, 0, 1, 2, 3, 4, 5, 6} ). Batch -1 is 2 months old at the start of period 1, batch 0 is 1 month old.

Parameters:
Demand_{rt}: Forecasted demand for product in region (r \in R) in period (t \in T).
R1: [100, 110, 120, 130, 140, 150]
R2: [80, 85, 90, 95, 100, 105]
R3: [120, 125, 130, 135, 140, 145]
R4: [60, 65, 70, 75, 80, 85]

ProdCap_{pt}: Production capacity of plant (p \in P) in period (t \in T).
P1: [250, 250, 260, 260, 270, 270]
P2: [200, 200, 210, 210, 220, 220]

FixedDCost_d: Fixed cost to operate DC (d \in D) per period.
DC1: $10,000; DC2: $12,000; DC3: $9,000

DCStoreCap_d: Storage capacity of DC (d \in D).
DC1: 300; DC2: 350; DC3: 280

TransCostPD_{pd}: Unit transportation cost from plant (p \in P) to DC (d \in D).
P1 to (DC1, DC2, DC3): [$5, $7, $6]
P2 to (DC1, DC2, DC3): [$8, $4, $7]

TransCostDR_{dr}: Unit transportation cost from DC (d \in D) to region (r \in R).
DC1 to (R1, R2, R3, R4): [$3, $4, $5, $6]
DC2 to (R1, R2, R3, R4): [$5, $3, $4, $5]
DC3 to (R1, R2, R3, R4): [$4, $5, $3, $4]

InvHoldCost_d: Unit inventory holding cost at DC (d \in D) per period.
DC1: $2.0; DC2: $2.5; DC3: $1.8

DisposalCost_d: Unit cost for disposing an expired product at DC (d \in D).
DC1: $1; DC2: $1; DC3: $1

ShortageCost_r: Unit shortage cost for region (r \in R) per period.
R1, R2, R3, R4: $50

L = 2: Shelf life of the product in months.
InitialInv_{db}: Initial inventory at DC (d \in D) from batch (b \in B) (where (b \le 0)).
InitialInv = { (DC1, -1): 5, (DC1, 0): 10 }; all other initial inventories are 0.

BigM_flow: A sufficiently large number, e.g., 2400.

Decision Variables
Y_{dt} \in \{0,1\}: Binary variable, 1 if DC (d \in D) is open in period (t \in T); 0 otherwise.
P_{pt} \ge 0: Continuous variable, quantity produced at plant (p \in P) in period (t \in T).
Q_{pdt} \ge 0: Continuous variable, quantity shipped from plant (p \in P) to DC (d \in D) in period (t \in T). This product belongs to batch t.
S_{bdrt} \ge 0: Continuous variable, quantity shipped from DC (d \in D) to region (r \in R) in period (t \in T), taken from batch (b \in B).
I_{bdt} \ge 0: Continuous variable, inventory at DC (d \in D) at the end of period (t \in T), originating from batch (b \in B).
U_{rt} \ge 0: Continuous variable, unmet demand (shortage) in region (r \in R) in period (t \in T).
W_{bdt} \ge 0: Continuous variable, quantity of product from batch (b \in B) that expires (is wasted) at DC (d \in D) at the end of period (t \in T).

Objective Function
Minimize total cost Z:
( \min Z = \sum_{d \in D} \sum_{t \in T} \text{FixedDCost}d \cdot Y{dt} ) (Fixed Costs)
( + \sum_{p \in P} \sum_{d \in D} \sum_{t \in T} \text{TransCostPD}{pd} \cdot Q{pdt} ) (Plant-to-DC Transport)
( + \sum_{b \in B} \sum_{d \in D} \sum_{r \in R} \sum_{t \in T} \text{TransCostDR}{dr} \cdot S{bdrt} ) (DC-to-Region Transport)
( + \sum_{b \in B} \sum_{d \in D} \sum_{t \in T} \text{InvHoldCost}d \cdot I{bdt} ) (Inventory Holding)
( + \sum_{b \in B} \sum_{d \in D} \sum_{t \in T} \text{DisposalCost}d \cdot W{bdt} ) (Disposal)
( + \sum_{r \in R} \sum_{t \in T} \text{ShortageCost}r \cdot U{rt} ) (Shortage)

Constraints
DC Activation & Capacity:

Total inventory at DC (d) cannot exceed its capacity if open:
( \sum_{b \in B} I_{bdt} \le \text{DCStoreCap}d \cdot Y{dt} ), for all (d \in D, t \in T ).

Shipments into a DC (d) can only occur if it's open:
( \sum_{p \in P} Q_{pdt} \le \text{BigM_flow} \cdot Y_{dt} ), for all (d \in D, t \in T ).

Shipments out of a DC (d) can only occur if it's open:
( \sum_{b \in B} \sum_{r \in R} S_{bdrt} \le \text{BigM_flow} \cdot Y_{dt} ), for all (d \in D, t \in T ).

Production & Plant Flow:

Production at plant (p) cannot exceed its capacity:
( P_{pt} \le \text{ProdCap}_{pt} ), for all (p \in P, t \in T ).

All products produced at plant (p) are shipped to DCs:
( P_{pt} = \sum_{d \in D} Q_{pdt} ), for all (p \in P, t \in T ).

Inventory Balance & Shelf Life: For all DCs (d), periods (t), and batches (b):

Inventory Balance:
Let (I_{bd,t-1}) be the inventory from the previous period (for (t=1), this is InitialInv).
Let (\text{Inflow}{bdt}) be the new stock from production (equals (\sum_p Q{pdt}) if (b=t), else 0).
Let (\text{Outflow}{bdt}) be the shipments to customers ((\sum_r S{bdrt})).
Then: ( I_{bdt} = I_{bd,t-1} + \text{Inflow}{bdt} - \text{Outflow}{bdt} - W_{bdt} )

Shelf Life: Product from batch b expires at the end of period b+L-1.
Waste can only occur at the expiration period: ( W_{bdt} = 0 ), if ( t \neq b+L-1 ).
Inventory must be zero after expiration: ( I_{bdt} = 0 ), if ( t \geq b+L-1 ).

Demand Satisfaction: For all regions (r) and periods (t):

Total quantity shipped to region (r) from all usable batches plus unmet demand equals the demand. A batch b is usable in period t if it's not expired (t < b+L).
( \sum_{d \in D} \sum_{b \in B : t < b+L} S_{bdrt} + U_{rt} = \text{Demand}_{rt} ).

Type
Mixed, Linear, Linear
MILP

Gurobi implementation:
import gurobipy as gp
from gurobipy import GRB


def solve_pharma_distribution_corrected(
    T=range(1, 7),
    P=['P1', 'P2'],
    D=['DC1', 'DC2', 'DC3'],
    R=['R1', 'R2', 'R3', 'R4'],
    B=range(-1, 7),
    L=2,
    BigM_flow=2400,
    Demand={
        ('R1', 1): 100,
        ('R1', 2): 110,
        ('R1', 3): 120,
        ('R1', 4): 130,
        ('R1', 5): 140,
        ('R1', 6): 150,
        ('R2', 1): 80,
        ('R2', 2): 85,
        ('R2', 3): 90,
        ('R2', 4): 95,
        ('R2', 5): 100,
        ('R2', 6): 105,
        ('R3', 1): 120,
        ('R3', 2): 125,
        ('R3', 3): 130,
        ('R3', 4): 135,
        ('R3', 5): 140,
        ('R3', 6): 145,
        ('R4', 1): 60,
        ('R4', 2): 65,
        ('R4', 3): 70,
        ('R4', 4): 75,
        ('R4', 5): 80,
        ('R4', 6): 85
    },
    ProdCap={
        ('P1', 1): 250,
        ('P1', 2): 250,
        ('P1', 3): 260,
        ('P1', 4): 260,
        ('P1', 5): 270,
        ('P1', 6): 270,
        ('P2', 1): 200,
        ('P2', 2): 200,
        ('P2', 3): 210,
        ('P2', 4): 210,
        ('P2', 5): 220,
        ('P2', 6): 220
    },
    FixedDCost={'DC1': 10000, 'DC2': 12000, 'DC3': 9000},
    DCStoreCap={'DC1': 300, 'DC2': 350, 'DC3': 280},
    TransCostPD={
        ('P1', 'DC1'): 5,
        ('P1', 'DC2'): 7,
        ('P1', 'DC3'): 6,
        ('P2', 'DC1'): 8,
        ('P2', 'DC2'): 4,
        ('P2', 'DC3'): 7
    },
    TransCostDR={
        ('DC1', 'R1'): 3,
        ('DC1', 'R2'): 4,
        ('DC1', 'R3'): 5,
        ('DC1', 'R4'): 6,
        ('DC2', 'R1'): 5,
        ('DC2', 'R2'): 3,
        ('DC2', 'R3'): 4,
        ('DC2', 'R4'): 5,
        ('DC3', 'R1'): 4,
        ('DC3', 'R2'): 5,
        ('DC3', 'R3'): 3,
        ('DC3', 'R4'): 4
    },
    InvHoldCost={'DC1': 2.0, 'DC2': 2.5, 'DC3': 1.8},
    DisposalCost={'DC1': 1, 'DC2': 1, 'DC3': 1},
    ShortageCost={'R1': 50, 'R2': 50, 'R3': 50, 'R4': 50},
    InitialInv={('DC1', -1): 5, ('DC1', 0): 10}
):
    """
    Solves a corrected and robust version of the PharmaLogistics distribution network problem,
    accurately handling inventory flow and shelf life.
    """
    model = gp.Model("PharmaLogistics_Corrected")

    # ========== Decision Variables ==========
    Y = model.addVars(D, T, vtype=GRB.BINARY, name="Y_dc_open")
    P_prod = model.addVars(P, T, lb=0, name="P_prod")
    Q_ship_pd = model.addVars(P, D, T, lb=0, name="Q_ship_plant_dc")
    S_ship_dr = model.addVars(B, D, R, T, lb=0, name="S_ship_dc_region")
    I_inv = model.addVars(B, D, T, lb=0, name="I_inventory")
    U_unmet = model.addVars(R, T, lb=0, name="U_unmet_demand")
    W_waste = model.addVars(B, D, T, lb=0, name="W_waste")

    # ========== Objective Function ==========
    total_fixed_cost = gp.quicksum(FixedDCost[d] * Y[d, t] for d in D
                                   for t in T)
    total_transport_pd_cost = gp.quicksum(
        TransCostPD[p, d] * Q_ship_pd[p, d, t] for p in P for d in D
        for t in T)
    total_transport_dr_cost = gp.quicksum(
        TransCostDR[d, r] * S_ship_dr[b, d, r, t] for b in B for d in D
        for r in R for t in T)
    total_inventory_cost = gp.quicksum(InvHoldCost[d] * I_inv[b, d, t]
                                       for b in B for d in D for t in T)
    total_disposal_cost = gp.quicksum(DisposalCost[d] * W_waste[b, d, t]
                                      for b in B for d in D for t in T)
    total_shortage_cost = gp.quicksum(ShortageCost[r] * U_unmet[r, t]
                                      for r in R for t in T)

    model.setObjective(
        total_fixed_cost + total_transport_pd_cost +
        total_transport_dr_cost + total_inventory_cost +
        total_disposal_cost + total_shortage_cost, GRB.MINIMIZE)

    # ========== Constraints ==========
    # DC Activation and Capacity
    model.addConstrs((gp.quicksum(I_inv[b, d, t]
                                  for b in B) <= DCStoreCap[d] * Y[d, t]
                      for d in D for t in T),
                     name="DC_Capacity")
    model.addConstrs((gp.quicksum(Q_ship_pd[p, d, t]
                                  for p in P) <= BigM_flow * Y[d, t]
                      for d in D for t in T),
                     name="DC_Open_Inbound")
    model.addConstrs((gp.quicksum(S_ship_dr[b, d, r, t] for b in B
                                  for r in R) <= BigM_flow * Y[d, t]
                      for d in D for t in T),
                     name="DC_Open_Outbound")

    # Production and Plant Flow
    model.addConstrs((P_prod[p, t] <= ProdCap[p, t] for p in P for t in T),
                     name="Production_Capacity")
    model.addConstrs((P_prod[p, t] == gp.quicksum(Q_ship_pd[p, d, t]
                                                  for d in D) for p in P
                      for t in T),
                     name="Plant_Flow")

    # Inventory Balance and Shelf Life
    for d in D:
        for t in T:
            for b in B:
                # Previous inventory
                if t == 1:
                    prev_inv = InitialInv.get((d, b), 0)
                else:
                    prev_inv = I_inv[b, d, t - 1]

                # Inflow from production
                inflow = gp.quicksum(Q_ship_pd[p, d, t]
                                     for p in P) if b == t else 0

                # Outflow to customers
                outflow = gp.quicksum(S_ship_dr[b, d, r, t] for r in R)

                # Inventory Balance Equation
                model.addConstr(I_inv[b, d, t] == prev_inv + inflow -
                                outflow - W_waste[b, d, t],
                                name=f"Inv_Balance_{b}_{d}_{t}")

                # --- CORRECTED: Shelf-life constraints ---
                # Product from batch 'b' expires at the end of period 'b + L - 1'
                expiration_period = b + L - 1

                # If the current period 't' is after the expiration period, inventory must be zero
                if t > expiration_period:
                    model.addConstr(I_inv[b, d, t] == 0,
                                    f"Expired_Inv_Zero_{b}_{d}_{t}")

                # Waste can only occur AT the expiration period.
                if t == expiration_period:
                    # Waste is whatever is left from the previous period that isn't shipped out
                    model.addConstr(W_waste[b, d, t] == prev_inv - outflow,
                                    f"Waste_Calc_{b}_{d}_{t}")
                else:
                    model.addConstr(W_waste[b, d, t] == 0,
                                    f"No_Waste_{b}_{d}_{t}")

    # Demand Satisfaction
    for r in R:
        for t in T:
            # Sum of shipments from all DCs and all VALID (non-expired) batches
            valid_shipments = gp.quicksum(S_ship_dr[b, d, r, t] for d in D
                                          for b in B if t < b + L)
            model.addConstr(valid_shipments + U_unmet[r, t] == Demand[r,
                                                                      t],
                            name=f"Demand_{r}_{t}")

    # ========== Solve ==========
    model.Params.TimeLimit = 120  # 2-minute time limit for this complex model
    model.optimize()

    # ========== Return Results ==========
    if model.Status == GRB.OPTIMAL or model.Status == GRB.TIME_LIMIT:
        return {"status": "optimal", "obj": model.ObjVal}
    else:
        return {"status": f"{model.Status}"}


if __name__ == "__main__":
    result = solve_pharma_distribution_corrected()
    print(result)


Solution status: OPTIMAL
Optimal objective value: 81060