blackopsrepl commited on
Commit
e40294e
·
verified ·
1 Parent(s): c021a8d

Upload 31 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.12 base image
2
+ FROM python:3.12
3
+
4
+ # Install JDK 21 (required for solverforge-legacy)
5
+ RUN apt-get update && \
6
+ apt-get install -y wget gnupg2 && \
7
+ wget -O- https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor > /usr/share/keyrings/adoptium-archive-keyring.gpg && \
8
+ echo "deb [signed-by=/usr/share/keyrings/adoptium-archive-keyring.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list && \
9
+ apt-get update && \
10
+ apt-get install -y temurin-21-jdk && \
11
+ apt-get clean && \
12
+ rm -rf /var/lib/apt/lists/*
13
+
14
+ # Copy application files
15
+ COPY . .
16
+
17
+ # Install the application
18
+ RUN pip install --no-cache-dir -e .
19
+
20
+ # Expose port 8080
21
+ EXPOSE 8080
22
+
23
+ # Run the application
24
+ CMD ["run-app"]
README.md CHANGED
@@ -1,12 +1,79 @@
1
  ---
2
- title: Order Picking Python
3
- emoji: 🏢
4
- colorFrom: yellow
5
- colorTo: blue
6
  sdk: docker
 
7
  pinned: false
8
  license: apache-2.0
9
  short_description: SolverForge Quickstart for the Order Picking problem
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Order Picking (Python)
3
+ emoji: 🏭
4
+ colorFrom: gray
5
+ colorTo: green
6
  sdk: docker
7
+ app_port: 8080
8
  pinned: false
9
  license: apache-2.0
10
  short_description: SolverForge Quickstart for the Order Picking problem
11
  ---
12
 
13
+ # Order Picking (Python)
14
+
15
+ Optimize warehouse order picking by assigning items to trolleys and minimizing travel distance.
16
+
17
+ - [Prerequisites](#prerequisites)
18
+ - [Run the application](#run-the-application)
19
+ - [Test the application](#test-the-application)
20
+
21
+ ## Prerequisites
22
+
23
+ 1. Install [Python 3.11 or 3.12](https://www.python.org/downloads/).
24
+
25
+ 2. Install JDK 17+, for example with [Sdkman](https://sdkman.io):
26
+
27
+ ```sh
28
+ $ sdk install java
29
+ ```
30
+
31
+ ## Run the application
32
+
33
+ 1. Git clone the solverforge-solver-python repo and navigate to this directory:
34
+
35
+ ```sh
36
+ $ git clone https://github.com/SolverForge/solverforge-quickstarts.git
37
+ ...
38
+ $ cd solverforge-quickstarts/fast/order-picking-fast
39
+ ```
40
+
41
+ 2. Create a virtual environment:
42
+
43
+ ```sh
44
+ $ python -m venv .venv
45
+ ```
46
+
47
+ 3. Activate the virtual environment:
48
+
49
+ ```sh
50
+ $ . .venv/bin/activate
51
+ ```
52
+
53
+ 4. Install the application:
54
+
55
+ ```sh
56
+ $ pip install -e .
57
+ ```
58
+
59
+ 5. Run the application:
60
+
61
+ ```sh
62
+ $ run-app
63
+ ```
64
+
65
+ 6. Visit [http://localhost:8080](http://localhost:8080) in your browser.
66
+
67
+ 7. Click on the **Solve** button.
68
+
69
+ ## Test the application
70
+
71
+ 1. Run tests:
72
+
73
+ ```sh
74
+ $ pytest
75
+ ```
76
+
77
+ ## More information
78
+
79
+ Visit [solverforge.org](https://www.solverforge.org).
logging.conf ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [loggers]
2
+ keys=root,timefold_solver
3
+
4
+ [handlers]
5
+ keys=consoleHandler
6
+
7
+ [formatters]
8
+ keys=simpleFormatter
9
+
10
+ [logger_root]
11
+ level=INFO
12
+ handlers=consoleHandler
13
+
14
+ [logger_timefold_solver]
15
+ level=INFO
16
+ qualname=timefold.solver
17
+ handlers=consoleHandler
18
+ propagate=0
19
+
20
+ [handler_consoleHandler]
21
+ class=StreamHandler
22
+ level=INFO
23
+ formatter=simpleFormatter
24
+ args=(sys.stdout,)
25
+
26
+ [formatter_simpleFormatter]
27
+ class=uvicorn.logging.ColourizedFormatter
28
+ format={levelprefix:<8} @ {name} : {message}
29
+ style={
30
+ use_colors=True
pyproject.toml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+
6
+ [project]
7
+ name = "order_picking"
8
+ version = "1.0.0"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ 'solverforge-legacy == 1.24.1',
12
+ 'fastapi == 0.111.0',
13
+ 'pydantic == 2.7.3',
14
+ 'uvicorn == 0.30.1',
15
+ 'pytest == 8.2.2',
16
+ ]
17
+
18
+
19
+ [project.scripts]
20
+ run-app = "order_picking:main"
src/order_picking/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uvicorn
2
+
3
+ from .rest_api import app as app
4
+
5
+
6
+ def main():
7
+ config = uvicorn.Config(
8
+ "order_picking:app",
9
+ host="0.0.0.0",
10
+ port=8080,
11
+ log_config="logging.conf",
12
+ use_colors=True,
13
+ )
14
+ server = uvicorn.Server(config)
15
+ server.run()
16
+
17
+
18
+ if __name__ == "__main__":
19
+ main()
src/order_picking/constraints.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver.score import (
2
+ constraint_provider,
3
+ ConstraintFactory,
4
+ HardSoftDecimalScore,
5
+ )
6
+
7
+ from .domain import Trolley
8
+
9
+
10
+ REQUIRED_NUMBER_OF_BUCKETS = "Required number of buckets"
11
+ MINIMIZE_ORDER_SPLIT = "Minimize order split by trolley"
12
+ MINIMIZE_DISTANCE = "Minimize total distance"
13
+
14
+
15
+ @constraint_provider
16
+ def define_constraints(factory: ConstraintFactory):
17
+ return [
18
+ # Hard constraints
19
+ required_number_of_buckets(factory),
20
+ # Soft constraints
21
+ minimize_order_split_by_trolley(factory),
22
+ minimize_total_distance(factory),
23
+ ]
24
+
25
+
26
+ def required_number_of_buckets(factory: ConstraintFactory):
27
+ """
28
+ Hard: Ensure trolley has enough buckets for all orders.
29
+ """
30
+ return (
31
+ factory.for_each(Trolley)
32
+ .filter(lambda trolley: trolley.calculate_excess_buckets() > 0)
33
+ .penalize(
34
+ HardSoftDecimalScore.ONE_HARD,
35
+ lambda trolley: trolley.calculate_excess_buckets()
36
+ )
37
+ .as_constraint(REQUIRED_NUMBER_OF_BUCKETS)
38
+ )
39
+
40
+
41
+ def minimize_order_split_by_trolley(factory: ConstraintFactory):
42
+ """
43
+ Soft: Orders should ideally be on the same trolley.
44
+ """
45
+ return (
46
+ factory.for_each(Trolley)
47
+ .filter(lambda trolley: len(trolley.steps) > 0)
48
+ .penalize(
49
+ HardSoftDecimalScore.ONE_SOFT,
50
+ lambda trolley: trolley.calculate_order_split_penalty()
51
+ )
52
+ .as_constraint(MINIMIZE_ORDER_SPLIT)
53
+ )
54
+
55
+
56
+ def minimize_total_distance(factory: ConstraintFactory):
57
+ """
58
+ Soft: Minimize total distance traveled by all trolleys.
59
+ Aggregated at Trolley level (like vehicle-routing) for performance.
60
+ """
61
+ return (
62
+ factory.for_each(Trolley)
63
+ .penalize(
64
+ HardSoftDecimalScore.ONE_SOFT,
65
+ lambda trolley: trolley.calculate_total_distance()
66
+ )
67
+ .as_constraint(MINIMIZE_DISTANCE)
68
+ )
src/order_picking/converters.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict
2
+ from solverforge_legacy.solver import SolverStatus
3
+ from solverforge_legacy.solver.score import HardSoftDecimalScore
4
+
5
+ from . import domain
6
+ from .warehouse import WarehouseLocation, Side
7
+
8
+
9
+ # =============================================================================
10
+ # Domain to API Model Conversion
11
+ # =============================================================================
12
+
13
+ def location_to_model(location: WarehouseLocation) -> domain.WarehouseLocationModel:
14
+ return domain.WarehouseLocationModel(
15
+ shelving_id=location.shelving_id,
16
+ side=location.side.name,
17
+ row=location.row
18
+ )
19
+
20
+
21
+ def product_to_model(product: domain.Product) -> domain.ProductModel:
22
+ return domain.ProductModel(
23
+ id=product.id,
24
+ name=product.name,
25
+ volume=product.volume,
26
+ location=location_to_model(product.location)
27
+ )
28
+
29
+
30
+ def order_item_to_model(item: domain.OrderItem) -> domain.OrderItemModel:
31
+ return domain.OrderItemModel(
32
+ id=item.id,
33
+ order_id=item.order_id,
34
+ product=product_to_model(item.product)
35
+ )
36
+
37
+
38
+ def order_to_model(order: domain.Order) -> domain.OrderModel:
39
+ return domain.OrderModel(
40
+ id=order.id,
41
+ items=[order_item_to_model(item) for item in order.items]
42
+ )
43
+
44
+
45
+ def trolley_step_to_model(step: domain.TrolleyStep) -> domain.TrolleyStepModel:
46
+ return domain.TrolleyStepModel(
47
+ id=step.id,
48
+ order_item=order_item_to_model(step.order_item),
49
+ trolley=step.trolley.id if step.trolley else None,
50
+ trolley_id=step.trolley_id
51
+ )
52
+
53
+
54
+ def trolley_to_model(trolley: domain.Trolley) -> domain.TrolleyModel:
55
+ return domain.TrolleyModel(
56
+ id=trolley.id,
57
+ bucket_count=trolley.bucket_count,
58
+ bucket_capacity=trolley.bucket_capacity,
59
+ location=location_to_model(trolley.location),
60
+ steps=[step.id for step in trolley.steps]
61
+ )
62
+
63
+
64
+ def solution_to_model(solution: domain.OrderPickingSolution) -> domain.OrderPickingSolutionModel:
65
+ return domain.OrderPickingSolutionModel(
66
+ trolleys=[trolley_to_model(t) for t in solution.trolleys],
67
+ trolley_steps=[trolley_step_to_model(s) for s in solution.trolley_steps],
68
+ score=str(solution.score) if solution.score else None,
69
+ solver_status=solution.solver_status.name if solution.solver_status else None
70
+ )
71
+
72
+
73
+ # =============================================================================
74
+ # API Model to Domain Conversion
75
+ # =============================================================================
76
+
77
+ def model_to_location(model: domain.WarehouseLocationModel) -> WarehouseLocation:
78
+ return WarehouseLocation(
79
+ shelving_id=model.shelving_id,
80
+ side=Side[model.side],
81
+ row=model.row
82
+ )
83
+
84
+
85
+ def model_to_product(model: domain.ProductModel) -> domain.Product:
86
+ return domain.Product(
87
+ id=model.id,
88
+ name=model.name,
89
+ volume=model.volume,
90
+ location=model_to_location(model.location)
91
+ )
92
+
93
+
94
+ def model_to_solution(model: domain.OrderPickingSolutionModel) -> domain.OrderPickingSolution:
95
+ """Convert API model to domain object."""
96
+ # First pass: create all products and orders without cross-references
97
+ products: Dict[str, domain.Product] = {}
98
+ orders: Dict[str, domain.Order] = {}
99
+
100
+ # Extract unique products and orders from trolley steps
101
+ for step_model in model.trolley_steps:
102
+ item_model = step_model.order_item
103
+ product_model = item_model.product
104
+
105
+ # Create product if not seen
106
+ if product_model.id not in products:
107
+ products[product_model.id] = model_to_product(product_model)
108
+
109
+ # Create order if not seen
110
+ order_id = item_model.order_id
111
+ if order_id and order_id not in orders:
112
+ orders[order_id] = domain.Order(id=order_id, items=[])
113
+
114
+ # Second pass: create order items and trolley steps
115
+ trolley_steps = []
116
+ step_lookup: Dict[str, domain.TrolleyStep] = {}
117
+
118
+ for step_model in model.trolley_steps:
119
+ item_model = step_model.order_item
120
+ product = products[item_model.product.id]
121
+ order = orders.get(item_model.order_id) if item_model.order_id else None
122
+
123
+ order_item = domain.OrderItem(
124
+ id=item_model.id,
125
+ order=order,
126
+ product=product
127
+ )
128
+
129
+ # Add item to order's item list
130
+ if order:
131
+ order.items.append(order_item)
132
+
133
+ step = domain.TrolleyStep(
134
+ id=step_model.id,
135
+ order_item=order_item
136
+ )
137
+ trolley_steps.append(step)
138
+ step_lookup[step.id] = step
139
+
140
+ # Third pass: create trolleys and set up relationships
141
+ trolleys = []
142
+ trolley_lookup: Dict[str, domain.Trolley] = {}
143
+
144
+ for trolley_model in model.trolleys:
145
+ trolley = domain.Trolley(
146
+ id=trolley_model.id,
147
+ bucket_count=trolley_model.bucket_count,
148
+ bucket_capacity=trolley_model.bucket_capacity,
149
+ location=model_to_location(trolley_model.location),
150
+ steps=[]
151
+ )
152
+ trolleys.append(trolley)
153
+ trolley_lookup[trolley.id] = trolley
154
+
155
+ # Populate steps list
156
+ for step_ref in trolley_model.steps:
157
+ step_id = step_ref if isinstance(step_ref, str) else step_ref.id
158
+ if step_id in step_lookup:
159
+ step = step_lookup[step_id]
160
+ trolley.steps.append(step)
161
+ # Set shadow variable (for consistency, though solver will reset)
162
+ step.trolley = trolley
163
+
164
+ # Set up previous/next step references based on list order
165
+ for trolley in trolleys:
166
+ for i, step in enumerate(trolley.steps):
167
+ step.previous_step = trolley.steps[i - 1] if i > 0 else None
168
+ step.next_step = trolley.steps[i + 1] if i < len(trolley.steps) - 1 else None
169
+
170
+ # Handle score
171
+ score = None
172
+ if model.score:
173
+ score = HardSoftDecimalScore.parse(model.score)
174
+
175
+ # Handle solver status
176
+ solver_status = SolverStatus.NOT_SOLVING
177
+ if model.solver_status:
178
+ solver_status = SolverStatus[model.solver_status]
179
+
180
+ return domain.OrderPickingSolution(
181
+ trolleys=trolleys,
182
+ trolley_steps=trolley_steps,
183
+ score=score,
184
+ solver_status=solver_status
185
+ )
src/order_picking/demo_data.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from random import Random
4
+ from typing import List
5
+
6
+ from .domain import Product, Order, OrderItem, Trolley, TrolleyStep, OrderPickingSolution
7
+ from .warehouse import WarehouseLocation, Side, Column, Row, new_shelving_id, Shelving
8
+
9
+
10
+ # Configuration constants - matches Java timefold-quickstarts
11
+ TROLLEYS_COUNT = 5
12
+ BUCKET_COUNT = 4
13
+ BUCKET_CAPACITY = 60 * 40 * 20 # 48000 cm3
14
+ ORDERS_COUNT = 8
15
+ ORDER_ITEMS_SIZE_MINIMUM = 1
16
+
17
+ # Start location for all trolleys
18
+ START_LOCATION = WarehouseLocation(
19
+ shelving_id=new_shelving_id(Column.COL_A, Row.ROW_1),
20
+ side=Side.LEFT,
21
+ row=0
22
+ )
23
+
24
+
25
+ class ProductFamily(Enum):
26
+ GENERAL_FOOD = "GENERAL_FOOD"
27
+ FRESH_FOOD = "FRESH_FOOD"
28
+ MEET_AND_FISH = "MEET_AND_FISH"
29
+ FROZEN_PRODUCTS = "FROZEN_PRODUCTS"
30
+ FRUITS_AND_VEGETABLES = "FRUITS_AND_VEGETABLES"
31
+ HOUSE_CLEANING = "HOUSE_CLEANING"
32
+ DRINKS = "DRINKS"
33
+ SNACKS = "SNACKS"
34
+ PETS = "PETS"
35
+
36
+
37
+ @dataclass
38
+ class ProductTemplate:
39
+ """Template for a product before location is assigned."""
40
+ id: str
41
+ name: str
42
+ volume: int # in cm3
43
+ family: ProductFamily
44
+
45
+
46
+ # Product templates without locations (locations are assigned randomly)
47
+ PRODUCT_TEMPLATES: List[ProductTemplate] = [
48
+ # GENERAL_FOOD
49
+ ProductTemplate("0", "Kelloggs Cornflakes", 30 * 12 * 35, ProductFamily.GENERAL_FOOD),
50
+ ProductTemplate("1", "Cream Crackers", 23 * 7 * 2, ProductFamily.GENERAL_FOOD),
51
+ ProductTemplate("2", "Tea Bags 240 packet", 2 * 6 * 15, ProductFamily.GENERAL_FOOD),
52
+ ProductTemplate("3", "Tomato Soup Can", 10 * 10 * 10, ProductFamily.GENERAL_FOOD),
53
+ ProductTemplate("4", "Baked Beans in Tomato Sauce", 10 * 10 * 10, ProductFamily.GENERAL_FOOD),
54
+ ProductTemplate("5", "Classic Mint Sauce", 8 * 10 * 8, ProductFamily.GENERAL_FOOD),
55
+ ProductTemplate("6", "Raspberry Conserve", 8 * 10 * 8, ProductFamily.GENERAL_FOOD),
56
+ ProductTemplate("7", "Orange Fine Shred Marmalade", 7 * 8 * 7, ProductFamily.GENERAL_FOOD),
57
+
58
+ # FRESH_FOOD
59
+ ProductTemplate("8", "Free Range Eggs 6 Pack", 15 * 10 * 8, ProductFamily.FRESH_FOOD),
60
+ ProductTemplate("9", "Mature Cheddar 400G", 10 * 9 * 5, ProductFamily.FRESH_FOOD),
61
+ ProductTemplate("10", "Butter Packet", 12 * 5 * 5, ProductFamily.FRESH_FOOD),
62
+
63
+ # FRUITS_AND_VEGETABLES
64
+ ProductTemplate("11", "Iceberg Lettuce Each", 2500, ProductFamily.FRUITS_AND_VEGETABLES),
65
+ ProductTemplate("12", "Carrots 1Kg", 1000, ProductFamily.FRUITS_AND_VEGETABLES),
66
+ ProductTemplate("13", "Organic Fair Trade Bananas 5 Pack", 1800, ProductFamily.FRUITS_AND_VEGETABLES),
67
+ ProductTemplate("14", "Gala Apple Minimum 5 Pack", 25 * 20 * 10, ProductFamily.FRUITS_AND_VEGETABLES),
68
+ ProductTemplate("15", "Orange Bag 3kg", 29 * 20 * 15, ProductFamily.FRUITS_AND_VEGETABLES),
69
+
70
+ # HOUSE_CLEANING
71
+ ProductTemplate("16", "Fairy Non Biological Laundry Liquid 4.55L", 5000, ProductFamily.HOUSE_CLEANING),
72
+ ProductTemplate("17", "Toilet Tissue 8 Roll White", 50 * 20 * 20, ProductFamily.HOUSE_CLEANING),
73
+ ProductTemplate("18", "Kitchen Roll 200 Sheets x 2", 30 * 30 * 15, ProductFamily.HOUSE_CLEANING),
74
+ ProductTemplate("19", "Stainless Steel Cleaner 500Ml", 500, ProductFamily.HOUSE_CLEANING),
75
+ ProductTemplate("20", "Antibacterial Surface Spray", 12 * 4 * 25, ProductFamily.HOUSE_CLEANING),
76
+
77
+ # MEET_AND_FISH
78
+ ProductTemplate("21", "Beef Lean Steak Mince 500g", 500, ProductFamily.MEET_AND_FISH),
79
+ ProductTemplate("22", "Smoked Salmon 120G", 150, ProductFamily.MEET_AND_FISH),
80
+ ProductTemplate("23", "Steak Burgers 454G", 450, ProductFamily.MEET_AND_FISH),
81
+ ProductTemplate("24", "Pork Cooked Ham 125G", 125, ProductFamily.MEET_AND_FISH),
82
+ ProductTemplate("25", "Chicken Breast Fillets 300G", 300, ProductFamily.MEET_AND_FISH),
83
+
84
+ # DRINKS
85
+ ProductTemplate("26", "6 Milk Bricks Pack", 22 * 16 * 21, ProductFamily.DRINKS),
86
+ ProductTemplate("27", "Milk Brick", 1232, ProductFamily.DRINKS),
87
+ ProductTemplate("28", "Skimmed Milk 2.5L", 2500, ProductFamily.DRINKS),
88
+ ProductTemplate("29", "3L Orange Juice", 3 * 1000, ProductFamily.DRINKS),
89
+ ProductTemplate("30", "Alcohol Free Beer 4 Pack", 30 * 15 * 30, ProductFamily.DRINKS),
90
+ ProductTemplate("31", "Pepsi Regular Bottle", 1000, ProductFamily.DRINKS),
91
+ ProductTemplate("32", "Pepsi Diet 6 x 330ml", 35 * 12 * 12, ProductFamily.DRINKS),
92
+ ProductTemplate("33", "Schweppes Lemonade 2L", 2000, ProductFamily.DRINKS),
93
+ ProductTemplate("34", "Coke Zero 8 x 330ml", 40 * 12 * 12, ProductFamily.DRINKS),
94
+ ProductTemplate("35", "Natural Mineral Water Still 6 X 1.5Ltr", 6 * 1500, ProductFamily.DRINKS),
95
+
96
+ # SNACKS
97
+ ProductTemplate("36", "Cocktail Crisps 6 Pack", 20 * 10 * 10, ProductFamily.SNACKS),
98
+ ]
99
+
100
+ # Shelving assignments per product family
101
+ SHELVINGS_PER_FAMILY = {
102
+ ProductFamily.FRUITS_AND_VEGETABLES: [
103
+ new_shelving_id(Column.COL_A, Row.ROW_1),
104
+ new_shelving_id(Column.COL_A, Row.ROW_2),
105
+ ],
106
+ ProductFamily.FRESH_FOOD: [
107
+ new_shelving_id(Column.COL_A, Row.ROW_3),
108
+ ],
109
+ ProductFamily.MEET_AND_FISH: [
110
+ new_shelving_id(Column.COL_B, Row.ROW_2),
111
+ new_shelving_id(Column.COL_B, Row.ROW_3),
112
+ ],
113
+ ProductFamily.FROZEN_PRODUCTS: [
114
+ new_shelving_id(Column.COL_B, Row.ROW_2),
115
+ new_shelving_id(Column.COL_B, Row.ROW_1),
116
+ ],
117
+ ProductFamily.DRINKS: [
118
+ new_shelving_id(Column.COL_D, Row.ROW_1),
119
+ ],
120
+ ProductFamily.SNACKS: [
121
+ new_shelving_id(Column.COL_D, Row.ROW_2),
122
+ ],
123
+ ProductFamily.GENERAL_FOOD: [
124
+ new_shelving_id(Column.COL_B, Row.ROW_2),
125
+ new_shelving_id(Column.COL_C, Row.ROW_3),
126
+ new_shelving_id(Column.COL_D, Row.ROW_2),
127
+ new_shelving_id(Column.COL_D, Row.ROW_3),
128
+ ],
129
+ ProductFamily.HOUSE_CLEANING: [
130
+ new_shelving_id(Column.COL_E, Row.ROW_2),
131
+ new_shelving_id(Column.COL_E, Row.ROW_1),
132
+ ],
133
+ ProductFamily.PETS: [
134
+ new_shelving_id(Column.COL_E, Row.ROW_3),
135
+ ],
136
+ }
137
+
138
+
139
+ def get_max_product_size() -> int:
140
+ """Get the maximum product volume."""
141
+ return max(p.volume for p in PRODUCT_TEMPLATES)
142
+
143
+
144
+ def validate_bucket_capacity(bucket_capacity: int) -> None:
145
+ """Ensure bucket capacity can hold the largest product."""
146
+ max_size = get_max_product_size()
147
+ if bucket_capacity < max_size:
148
+ raise ValueError(
149
+ f"The selected bucketCapacity: {bucket_capacity}, is lower than the "
150
+ f"maximum product size: {max_size}. Please use a higher value."
151
+ )
152
+
153
+
154
+ def build_products(random: Random) -> List[Product]:
155
+ """Build products with random warehouse locations based on their family."""
156
+ products = []
157
+ for template in PRODUCT_TEMPLATES:
158
+ shelving_ids = SHELVINGS_PER_FAMILY[template.family]
159
+ shelving_id = random.choice(shelving_ids)
160
+ side = random.choice(list(Side))
161
+ row = random.randint(1, Shelving.ROWS_SIZE)
162
+
163
+ location = WarehouseLocation(
164
+ shelving_id=shelving_id,
165
+ side=side,
166
+ row=row
167
+ )
168
+ products.append(Product(
169
+ id=template.id,
170
+ name=template.name,
171
+ volume=template.volume,
172
+ location=location
173
+ ))
174
+ return products
175
+
176
+
177
+ def build_trolleys(
178
+ count: int,
179
+ bucket_count: int,
180
+ bucket_capacity: int,
181
+ start_location: WarehouseLocation
182
+ ) -> List[Trolley]:
183
+ """Build trolleys at the start location."""
184
+ return [
185
+ Trolley(
186
+ id=str(i),
187
+ bucket_count=bucket_count,
188
+ bucket_capacity=bucket_capacity,
189
+ location=start_location
190
+ )
191
+ for i in range(1, count + 1)
192
+ ]
193
+
194
+
195
+ def build_orders(count: int, products: List[Product], random: Random) -> List[Order]:
196
+ """Build orders with random products - matches Java implementation."""
197
+ orders = []
198
+ for order_num in range(1, count + 1):
199
+ # Java: ORDER_ITEMS_SIZE_MINIMUM + random.nextInt(products.size() - ORDER_ITEMS_SIZE_MINIMUM)
200
+ order_items_size = ORDER_ITEMS_SIZE_MINIMUM + random.randint(0, len(products) - ORDER_ITEMS_SIZE_MINIMUM - 1)
201
+
202
+ order_items = []
203
+ order_product_ids = set()
204
+ order = Order(id=str(order_num), items=order_items)
205
+
206
+ item_num = 1
207
+ for _ in range(order_items_size):
208
+ product_index = random.randint(0, len(products) - 1)
209
+ product = products[product_index]
210
+ # Avoid duplicate products in the same order
211
+ if product.id not in order_product_ids:
212
+ order_items.append(OrderItem(
213
+ id=str(item_num),
214
+ order=order,
215
+ product=product
216
+ ))
217
+ order_product_ids.add(product.id)
218
+ item_num += 1
219
+
220
+ orders.append(order)
221
+ return orders
222
+
223
+
224
+ def build_trolley_steps(orders: List[Order]) -> List[TrolleyStep]:
225
+ """Build trolley steps from order items."""
226
+ steps = []
227
+ for order in orders:
228
+ for idx, item in enumerate(order.items):
229
+ steps.append(TrolleyStep(
230
+ id=f"{order.id}-{idx}",
231
+ order_item=item
232
+ ))
233
+ return steps
234
+
235
+
236
+ def generate_demo_data() -> OrderPickingSolution:
237
+ """Generate the complete demo data set."""
238
+ random = Random(37) # Fixed seed for reproducibility
239
+
240
+ validate_bucket_capacity(BUCKET_CAPACITY)
241
+
242
+ products = build_products(random)
243
+ trolleys = build_trolleys(TROLLEYS_COUNT, BUCKET_COUNT, BUCKET_CAPACITY, START_LOCATION)
244
+ orders = build_orders(ORDERS_COUNT, products, random)
245
+ trolley_steps = build_trolley_steps(orders)
246
+
247
+ # Pre-assign steps evenly across trolleys so we have paths to visualize immediately
248
+ # The solver will optimize the distribution
249
+ if trolleys:
250
+ for i, step in enumerate(trolley_steps):
251
+ trolley = trolleys[i % len(trolleys)]
252
+ trolley.steps.append(step)
253
+ step.trolley = trolley
254
+
255
+ return OrderPickingSolution(
256
+ trolleys=trolleys,
257
+ trolley_steps=trolley_steps
258
+ )
src/order_picking/domain.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+ from typing import Annotated, Optional, List, Union
3
+
4
+ from solverforge_legacy.solver import SolverStatus
5
+ from solverforge_legacy.solver.score import HardSoftDecimalScore
6
+ from solverforge_legacy.solver.domain import (
7
+ planning_entity,
8
+ planning_solution,
9
+ PlanningId,
10
+ PlanningScore,
11
+ PlanningListVariable,
12
+ PlanningEntityCollectionProperty,
13
+ ValueRangeProvider,
14
+ InverseRelationShadowVariable,
15
+ PreviousElementShadowVariable,
16
+ NextElementShadowVariable,
17
+ CascadingUpdateShadowVariable,
18
+ )
19
+
20
+ from .warehouse import WarehouseLocation, Side
21
+ from .json_serialization import JsonDomainBase
22
+ from pydantic import Field
23
+
24
+
25
+ # =============================================================================
26
+ # Domain Classes (used internally by solver - @dataclass for performance)
27
+ # =============================================================================
28
+
29
+ @dataclass
30
+ class Product:
31
+ """A store product that can be included in an order."""
32
+ id: str
33
+ name: str
34
+ volume: int # in cm3
35
+ location: WarehouseLocation
36
+
37
+
38
+ @dataclass
39
+ class Order:
40
+ """Represents an order submitted by a customer."""
41
+ id: str
42
+ items: list["OrderItem"] = field(default_factory=list)
43
+
44
+
45
+ @dataclass
46
+ class OrderItem:
47
+ """An indivisible product added to an order."""
48
+ id: str
49
+ order: Order
50
+ product: Product
51
+
52
+ @property
53
+ def volume(self) -> int:
54
+ return self.product.volume
55
+
56
+ @property
57
+ def order_id(self) -> str:
58
+ return self.order.id if self.order else None
59
+
60
+ @property
61
+ def location(self) -> WarehouseLocation:
62
+ return self.product.location
63
+
64
+
65
+ @planning_entity
66
+ @dataclass
67
+ class TrolleyStep:
68
+ """
69
+ Represents a 'stop' in a Trolley's path where an order item is to be picked.
70
+
71
+ Shadow variables automatically track the trolley assignment and position in the list.
72
+ The distance_from_previous is a cascading shadow variable that precomputes distance.
73
+ """
74
+ id: Annotated[str, PlanningId]
75
+ order_item: OrderItem
76
+
77
+ # Shadow variables - automatically maintained by solver
78
+ trolley: Annotated[
79
+ Optional["Trolley"],
80
+ InverseRelationShadowVariable(source_variable_name="steps")
81
+ ] = None
82
+
83
+ previous_step: Annotated[
84
+ Optional["TrolleyStep"],
85
+ PreviousElementShadowVariable(source_variable_name="steps")
86
+ ] = None
87
+
88
+ next_step: Annotated[
89
+ Optional["TrolleyStep"],
90
+ NextElementShadowVariable(source_variable_name="steps")
91
+ ] = None
92
+
93
+ # Cascading shadow variable - precomputes distance from previous element
94
+ # This is updated automatically when the step is assigned/moved
95
+ distance_from_previous: Annotated[
96
+ Optional[int],
97
+ CascadingUpdateShadowVariable(target_method_name="update_distance_from_previous")
98
+ ] = None
99
+
100
+ def update_distance_from_previous(self):
101
+ """Called automatically by solver when step is assigned/moved."""
102
+ from .warehouse import calculate_distance
103
+ if self.trolley is None:
104
+ self.distance_from_previous = None
105
+ elif self.previous_step is None:
106
+ # First step - distance from trolley start
107
+ self.distance_from_previous = calculate_distance(
108
+ self.trolley.location, self.location
109
+ )
110
+ else:
111
+ # Distance from previous step
112
+ self.distance_from_previous = calculate_distance(
113
+ self.previous_step.location, self.location
114
+ )
115
+
116
+ @property
117
+ def location(self) -> WarehouseLocation:
118
+ return self.order_item.location
119
+
120
+ @property
121
+ def is_last(self) -> bool:
122
+ return self.next_step is None
123
+
124
+ @property
125
+ def trolley_id(self) -> Optional[str]:
126
+ return self.trolley.id if self.trolley else None
127
+
128
+ def __str__(self) -> str:
129
+ return f"TrolleyStep({self.id})"
130
+
131
+ def __repr__(self) -> str:
132
+ return f"TrolleyStep({self.id})"
133
+
134
+
135
+ @planning_entity
136
+ @dataclass
137
+ class Trolley:
138
+ """
139
+ A trolley that will be filled with order items.
140
+
141
+ The steps list is the planning variable that the solver modifies.
142
+ """
143
+ id: Annotated[str, PlanningId]
144
+ bucket_count: int
145
+ bucket_capacity: int # in cm3
146
+ location: WarehouseLocation
147
+
148
+ # Planning variable - solver assigns TrolleySteps to this list
149
+ steps: Annotated[list[TrolleyStep], PlanningListVariable] = field(default_factory=list)
150
+
151
+ def total_capacity(self) -> int:
152
+ """Total volume capacity of this trolley."""
153
+ return self.bucket_count * self.bucket_capacity
154
+
155
+ def calculate_total_volume(self) -> int:
156
+ """Sum of volumes of all items assigned to this trolley."""
157
+ return sum(step.order_item.volume for step in self.steps)
158
+
159
+ def calculate_excess_volume(self) -> int:
160
+ """Volume exceeding capacity (0 if within capacity)."""
161
+ excess = self.calculate_total_volume() - self.total_capacity()
162
+ return max(0, excess)
163
+
164
+ def calculate_required_buckets(self) -> int:
165
+ """
166
+ Calculate total buckets needed for all orders on this trolley.
167
+ Buckets are NOT shared between orders - each order needs its own buckets.
168
+ """
169
+ if len(self.steps) == 0:
170
+ return 0
171
+ # Group steps by order and calculate buckets per order
172
+ order_volumes: dict = {}
173
+ for step in self.steps:
174
+ order = step.order_item.order
175
+ order_volumes[order.id] = order_volumes.get(order.id, 0) + step.order_item.volume
176
+ # Sum up required buckets (ceiling division for each order)
177
+ total_buckets = 0
178
+ for volume in order_volumes.values():
179
+ total_buckets += (volume + self.bucket_capacity - 1) // self.bucket_capacity
180
+ return total_buckets
181
+
182
+ def calculate_excess_buckets(self) -> int:
183
+ """Buckets needed beyond capacity (0 if within capacity)."""
184
+ excess = self.calculate_required_buckets() - self.bucket_count
185
+ return max(0, excess)
186
+
187
+ def calculate_order_split_penalty(self) -> int:
188
+ """
189
+ Penalty for orders split across trolleys.
190
+ Returns 1000 per unique order on this trolley (will be summed across all trolleys).
191
+ """
192
+ if len(self.steps) == 0:
193
+ return 0
194
+ unique_orders = set(step.order_item.order.id for step in self.steps)
195
+ return len(unique_orders) * 1000
196
+
197
+ def calculate_total_distance(self) -> int:
198
+ """
199
+ Calculate total distance for this trolley's route.
200
+ Uses precomputed distance_from_previous shadow variable for speed.
201
+ """
202
+ if len(self.steps) == 0:
203
+ return 0
204
+ from .warehouse import calculate_distance
205
+ # Sum precomputed distances (already includes start -> first step)
206
+ total = 0
207
+ for step in self.steps:
208
+ if step.distance_from_previous is not None:
209
+ total += step.distance_from_previous
210
+ # Add return trip from last step to origin
211
+ last_step = self.steps[-1]
212
+ total += calculate_distance(last_step.location, self.location)
213
+ return total
214
+
215
+ def __str__(self) -> str:
216
+ return f"Trolley({self.id})"
217
+
218
+ def __repr__(self) -> str:
219
+ return f"Trolley({self.id})"
220
+
221
+
222
+ @planning_solution
223
+ @dataclass
224
+ class OrderPickingSolution:
225
+ """The planning solution containing trolleys and steps to be optimized."""
226
+
227
+ trolleys: Annotated[list[Trolley], PlanningEntityCollectionProperty]
228
+
229
+ trolley_steps: Annotated[
230
+ list[TrolleyStep],
231
+ PlanningEntityCollectionProperty,
232
+ ValueRangeProvider
233
+ ]
234
+
235
+ score: Annotated[Optional[HardSoftDecimalScore], PlanningScore] = None
236
+ solver_status: SolverStatus = SolverStatus.NOT_SOLVING
237
+
238
+
239
+ # =============================================================================
240
+ # Pydantic API Models (for REST serialization only)
241
+ # =============================================================================
242
+
243
+ class WarehouseLocationModel(JsonDomainBase):
244
+ shelving_id: str = Field(..., alias="shelvingId")
245
+ side: str
246
+ row: int
247
+
248
+
249
+ class ProductModel(JsonDomainBase):
250
+ id: str
251
+ name: str
252
+ volume: int
253
+ location: WarehouseLocationModel
254
+
255
+
256
+ class OrderItemModel(JsonDomainBase):
257
+ id: str
258
+ order_id: Optional[str] = Field(None, alias="orderId")
259
+ product: ProductModel
260
+
261
+
262
+ class OrderModel(JsonDomainBase):
263
+ id: str
264
+ items: List[OrderItemModel] = Field(default_factory=list)
265
+
266
+
267
+ class TrolleyStepModel(JsonDomainBase):
268
+ id: str
269
+ order_item: OrderItemModel = Field(..., alias="orderItem")
270
+ trolley: Optional[Union[str, "TrolleyModel"]] = None
271
+ trolley_id: Optional[str] = Field(None, alias="trolleyId")
272
+
273
+
274
+ class TrolleyModel(JsonDomainBase):
275
+ id: str
276
+ bucket_count: int = Field(..., alias="bucketCount")
277
+ bucket_capacity: int = Field(..., alias="bucketCapacity")
278
+ location: WarehouseLocationModel
279
+ steps: List[Union[str, TrolleyStepModel]] = Field(default_factory=list)
280
+
281
+
282
+ class OrderPickingSolutionModel(JsonDomainBase):
283
+ trolleys: List[TrolleyModel]
284
+ trolley_steps: List[TrolleyStepModel] = Field(..., alias="trolleySteps")
285
+ score: Optional[str] = None
286
+ solver_status: Optional[str] = Field(None, alias="solverStatus")
src/order_picking/json_serialization.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver.score import HardSoftDecimalScore
2
+
3
+ from typing import Any
4
+ from pydantic import (
5
+ BaseModel,
6
+ ConfigDict,
7
+ PlainSerializer,
8
+ BeforeValidator,
9
+ ValidationInfo,
10
+ )
11
+ from pydantic.alias_generators import to_camel
12
+
13
+
14
+ class JsonDomainBase(BaseModel):
15
+ model_config = ConfigDict(
16
+ alias_generator=to_camel,
17
+ populate_by_name=True,
18
+ from_attributes=True,
19
+ serialize_by_alias=True, # Output camelCase in JSON responses
20
+ )
21
+
22
+
23
+ ScoreSerializer = PlainSerializer(
24
+ lambda score: str(score) if score is not None else None,
25
+ return_type=str | None
26
+ )
27
+
28
+ IdSerializer = PlainSerializer(
29
+ lambda item: item.id if item is not None else None,
30
+ return_type=str | None
31
+ )
32
+
33
+ IdListSerializer = PlainSerializer(
34
+ lambda items: [item.id for item in items],
35
+ return_type=list
36
+ )
37
+
38
+
39
+ def validate_score(v: Any, info: ValidationInfo) -> Any:
40
+ if isinstance(v, HardSoftDecimalScore) or v is None:
41
+ return v
42
+ if isinstance(v, str):
43
+ return HardSoftDecimalScore.parse(v)
44
+ raise ValueError('"score" should be a string')
45
+
46
+
47
+ ScoreValidator = BeforeValidator(validate_score)
src/order_picking/rest_api.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from fastapi.staticfiles import StaticFiles
3
+ from uuid import uuid4
4
+ from typing import Dict, List, Any
5
+ from dataclasses import dataclass, asdict
6
+ from threading import Lock, Event
7
+ import re
8
+ import copy
9
+
10
+ from pydantic import BaseModel, Field as PydanticField
11
+ from random import Random
12
+
13
+ from .domain import OrderPickingSolution, OrderPickingSolutionModel
14
+ from .converters import solution_to_model, model_to_solution
15
+ from .demo_data import (
16
+ generate_demo_data, build_products, build_trolleys, build_orders,
17
+ build_trolley_steps, validate_bucket_capacity, START_LOCATION, BUCKET_CAPACITY
18
+ )
19
+ from .solver import solver_manager, solution_manager
20
+ from .warehouse import calculate_distance_to_travel
21
+
22
+
23
+ app = FastAPI(docs_url='/q/swagger-ui')
24
+
25
+ # =============================================================================
26
+ # Thread-safe solution caching
27
+ # =============================================================================
28
+ # The solver runs in a Java thread (via JPype) and calls Python callbacks.
29
+ # We snapshot solution data IMMEDIATELY in the callback (while solver paused)
30
+ # and store the immutable snapshot. API handlers read from this cache.
31
+
32
+ # Thread-safe cache: stores SNAPSHOTS as dicts (immutable after creation)
33
+ cached_solutions: Dict[str, Dict[str, Any]] = {}
34
+ cached_distances: Dict[str, Dict[str, int]] = {}
35
+ cache_lock = Lock()
36
+
37
+ # Events to signal when first solution is ready
38
+ first_solution_events: Dict[str, Event] = {}
39
+
40
+ # Domain objects for score analysis
41
+ data_sets: Dict[str, OrderPickingSolution] = {}
42
+
43
+
44
+ @dataclass
45
+ class MatchAnalysisDTO:
46
+ name: str
47
+ score: str
48
+ justification: str
49
+
50
+
51
+ @dataclass
52
+ class ConstraintAnalysisDTO:
53
+ name: str
54
+ weight: str
55
+ score: str
56
+ matches: List[MatchAnalysisDTO]
57
+
58
+
59
+ class DemoConfigModel(BaseModel):
60
+ """Configuration for generating custom demo data."""
61
+ orders_count: int = PydanticField(default=40, ge=5, le=100, alias="ordersCount")
62
+ trolleys_count: int = PydanticField(default=8, ge=2, le=15, alias="trolleysCount")
63
+ bucket_count: int = PydanticField(default=6, ge=2, le=10, alias="bucketCount")
64
+
65
+ model_config = {"populate_by_name": True}
66
+
67
+
68
+ @app.get("/demo-data")
69
+ async def get_demo_data_list() -> List[str]:
70
+ """Get available demo data sets."""
71
+ return ["DEFAULT"]
72
+
73
+
74
+ @app.get("/demo-data/{demo_name}", response_model=OrderPickingSolutionModel)
75
+ async def get_demo_data_by_name(demo_name: str) -> OrderPickingSolutionModel:
76
+ """Get a specific demo data set."""
77
+ if demo_name != "DEFAULT":
78
+ raise HTTPException(status_code=404, detail=f"Demo data '{demo_name}' not found")
79
+ domain_solution = generate_demo_data()
80
+ return solution_to_model(domain_solution)
81
+
82
+
83
+ @app.post("/demo-data/generate", response_model=OrderPickingSolutionModel)
84
+ async def generate_custom_demo(config: DemoConfigModel) -> OrderPickingSolutionModel:
85
+ """Generate demo data with custom configuration."""
86
+ random = Random(37) # Fixed seed for reproducibility
87
+
88
+ validate_bucket_capacity(BUCKET_CAPACITY)
89
+
90
+ products = build_products(random)
91
+ trolleys = build_trolleys(
92
+ config.trolleys_count,
93
+ config.bucket_count,
94
+ BUCKET_CAPACITY,
95
+ START_LOCATION
96
+ )
97
+ orders = build_orders(config.orders_count, products, random)
98
+ trolley_steps = build_trolley_steps(orders)
99
+
100
+ # Pre-assign steps evenly across trolleys so we have paths to visualize immediately
101
+ if trolleys:
102
+ for i, step in enumerate(trolley_steps):
103
+ trolley = trolleys[i % len(trolleys)]
104
+ trolley.steps.append(step)
105
+ step.trolley = trolley
106
+
107
+ domain_solution = OrderPickingSolution(
108
+ trolleys=trolleys,
109
+ trolley_steps=trolley_steps
110
+ )
111
+ return solution_to_model(domain_solution)
112
+
113
+
114
+ def update_solution(job_id: str, solution: OrderPickingSolution):
115
+ """
116
+ Update solution cache. Called by solver callback from Java thread.
117
+
118
+ CRITICAL: We snapshot ALL data IMMEDIATELY in the callback while the solver
119
+ is paused. This prevents race conditions where the Java solver modifies
120
+ domain objects while we're reading them.
121
+ """
122
+ # Snapshot step assignments for each trolley FIRST (before any iteration)
123
+ trolley_snapshots = []
124
+ for t in solution.trolleys:
125
+ # Copy the steps list immediately - this is the critical snapshot
126
+ step_ids = [s.id for s in t.steps]
127
+ trolley_snapshots.append((t.id, len(step_ids), step_ids))
128
+
129
+ # Log for debugging
130
+ step_counts = [f"T{tid}:{count}" for tid, count, _ in trolley_snapshots]
131
+ print(f"[CALLBACK] job={job_id} score={solution.score} steps=[{' '.join(step_counts)}]")
132
+
133
+ # Now convert to API model (uses the same solution state we just logged)
134
+ api_model = solution_to_model(solution)
135
+ solution_dict = api_model.model_dump(by_alias=True)
136
+
137
+ # Calculate distances
138
+ distances = {}
139
+ for trolley in solution.trolleys:
140
+ distances[trolley.id] = calculate_distance_to_travel(trolley)
141
+
142
+ # Update cache atomically
143
+ with cache_lock:
144
+ cached_solutions[job_id] = solution_dict
145
+ cached_distances[job_id] = distances
146
+
147
+ # Signal that first solution is ready
148
+ if job_id in first_solution_events:
149
+ first_solution_events[job_id].set()
150
+
151
+ # Keep domain object reference for score analysis
152
+ data_sets[job_id] = solution
153
+
154
+
155
+ @app.post("/schedules")
156
+ async def solve(solution_model: OrderPickingSolutionModel) -> str:
157
+ """Submit a problem to solve. Returns job ID."""
158
+ job_id = str(uuid4())
159
+ domain_solution = model_to_solution(solution_model)
160
+
161
+ data_sets[job_id] = domain_solution
162
+
163
+ # Initialize cache with empty state - will be updated by callbacks
164
+ with cache_lock:
165
+ cached_solutions[job_id] = solution_to_model(domain_solution).model_dump(by_alias=True)
166
+ cached_distances[job_id] = {}
167
+
168
+ # Start solver - callbacks update cache when construction completes and on improvements
169
+ (solver_manager.solve_builder()
170
+ .with_problem_id(job_id)
171
+ .with_problem(domain_solution)
172
+ .with_first_initialized_solution_consumer(lambda solution: update_solution(job_id, solution))
173
+ .with_best_solution_consumer(lambda solution: update_solution(job_id, solution))
174
+ .run())
175
+
176
+ return job_id
177
+
178
+
179
+ @app.get("/schedules/{problem_id}")
180
+ async def get_solution(problem_id: str) -> Dict[str, Any]:
181
+ """Get the current solution for a given job ID."""
182
+ solver_status = solver_manager.get_solver_status(problem_id)
183
+
184
+ # Read from thread-safe cache (populated by solver callbacks)
185
+ with cache_lock:
186
+ cached = cached_solutions.get(problem_id)
187
+
188
+ if not cached:
189
+ raise HTTPException(status_code=404, detail="Solution not found")
190
+
191
+ # Return cached solution with current status
192
+ result = dict(cached)
193
+ result["solverStatus"] = solver_status.name if solver_status else None
194
+
195
+ return result
196
+
197
+
198
+ @app.get("/schedules/{problem_id}/status")
199
+ async def get_status(problem_id: str) -> dict:
200
+ """Get the solution status, score, and distances for a given job ID."""
201
+ # Read from thread-safe cache
202
+ with cache_lock:
203
+ cached = cached_solutions.get(problem_id)
204
+ distances = cached_distances.get(problem_id, {})
205
+
206
+ if not cached:
207
+ raise HTTPException(status_code=404, detail="Solution not found")
208
+
209
+ solver_status = solver_manager.get_solver_status(problem_id)
210
+
211
+ # Parse score from cached solution
212
+ score_str = cached.get("score", "")
213
+ hard_score = 0
214
+ soft_score = 0
215
+ if score_str:
216
+ # Parse score like "0hard/-12345soft"
217
+ match = re.match(r"(-?\d+)hard/(-?\d+)soft", str(score_str))
218
+ if match:
219
+ hard_score = int(match.group(1))
220
+ soft_score = int(match.group(2))
221
+
222
+ return {
223
+ "score": {
224
+ "hardScore": hard_score,
225
+ "softScore": soft_score,
226
+ },
227
+ "solverStatus": solver_status.name if solver_status else None,
228
+ "distances": distances,
229
+ }
230
+
231
+
232
+ @app.delete("/schedules/{problem_id}")
233
+ async def stop_solving(problem_id: str) -> Dict[str, Any]:
234
+ """Terminate solving for a given job ID. Returns the best solution so far."""
235
+ solver_manager.terminate_early(problem_id)
236
+
237
+ # Read from thread-safe cache
238
+ with cache_lock:
239
+ cached = cached_solutions.get(problem_id)
240
+ if not cached:
241
+ raise HTTPException(status_code=404, detail="Solution not found")
242
+ result = dict(cached)
243
+
244
+ solver_status = solver_manager.get_solver_status(problem_id)
245
+ result["solverStatus"] = solver_status.name if solver_status else None
246
+
247
+ return result
248
+
249
+
250
+ @app.get("/schedules/{problem_id}/score-analysis")
251
+ async def analyze_score(problem_id: str) -> dict:
252
+ """Get score analysis for current solution."""
253
+ import asyncio
254
+ from concurrent.futures import ThreadPoolExecutor
255
+
256
+ solution = data_sets.get(problem_id)
257
+ if not solution:
258
+ raise HTTPException(status_code=404, detail="Solution not found")
259
+
260
+ # Run blocking JPype call in thread pool to not block async event loop
261
+ loop = asyncio.get_event_loop()
262
+ with ThreadPoolExecutor() as pool:
263
+ analysis = await loop.run_in_executor(pool, solution_manager.analyze, solution)
264
+ constraints = []
265
+ for constraint in getattr(analysis, 'constraint_analyses', []) or []:
266
+ matches = [
267
+ MatchAnalysisDTO(
268
+ name=str(getattr(getattr(match, 'constraint_ref', None), 'constraint_name', "")),
269
+ score=str(getattr(match, 'score', "0hard/0soft")),
270
+ justification=str(getattr(match, 'justification', ""))
271
+ )
272
+ for match in getattr(constraint, 'matches', []) or []
273
+ ]
274
+ constraints.append(ConstraintAnalysisDTO(
275
+ name=str(getattr(constraint, 'constraint_name', "")),
276
+ weight=str(getattr(constraint, 'weight', "0hard/0soft")),
277
+ score=str(getattr(constraint, 'score', "0hard/0soft")),
278
+ matches=matches
279
+ ))
280
+ return {"constraints": [asdict(constraint) for constraint in constraints]}
281
+
282
+
283
+ @app.get("/schedules")
284
+ async def list_solutions() -> List[str]:
285
+ """List the job IDs of all submitted solutions."""
286
+ return list(data_sets.keys())
287
+
288
+
289
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
src/order_picking/solver.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver import SolverManager, SolutionManager
2
+ from solverforge_legacy.solver.config import (
3
+ SolverConfig,
4
+ ScoreDirectorFactoryConfig,
5
+ TerminationConfig,
6
+ Duration,
7
+ )
8
+
9
+ from .domain import Trolley, TrolleyStep, OrderPickingSolution
10
+ from .constraints import define_constraints
11
+
12
+
13
+ solver_config = SolverConfig(
14
+ solution_class=OrderPickingSolution,
15
+ entity_class_list=[Trolley, TrolleyStep],
16
+ score_director_factory_config=ScoreDirectorFactoryConfig(
17
+ constraint_provider_function=define_constraints
18
+ ),
19
+ termination_config=TerminationConfig(spent_limit=Duration(minutes=10)),
20
+ )
21
+
22
+ solver_manager = SolverManager.create(solver_config)
23
+ solution_manager = SolutionManager.create(solver_manager)
src/order_picking/warehouse.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from enum import Enum
3
+ from typing import TYPE_CHECKING, ClassVar
4
+
5
+ if TYPE_CHECKING:
6
+ from .domain import Trolley
7
+
8
+
9
+ class Side(Enum):
10
+ """Available shelving sides where products can be located."""
11
+ LEFT = "LEFT"
12
+ RIGHT = "RIGHT"
13
+
14
+
15
+ class Column(Enum):
16
+ """Defines the warehouse columns."""
17
+ COL_A = 'A'
18
+ COL_B = 'B'
19
+ COL_C = 'C'
20
+ COL_D = 'D'
21
+ COL_E = 'E'
22
+
23
+
24
+ class Row(Enum):
25
+ """Defines the warehouse rows."""
26
+ ROW_1 = 1
27
+ ROW_2 = 2
28
+ ROW_3 = 3
29
+
30
+
31
+ @dataclass
32
+ class Shelving:
33
+ """
34
+ Represents a products container. Each shelving has two sides where
35
+ products can be stored, and a number of rows.
36
+ """
37
+ id: str
38
+ x: int # Absolute x position of shelving's left bottom corner
39
+ y: int # Absolute y position of shelving's left bottom corner
40
+
41
+ ROWS_SIZE: ClassVar[int] = 10
42
+
43
+
44
+ @dataclass
45
+ class WarehouseLocation:
46
+ """
47
+ Represents a location in the warehouse where a product can be stored.
48
+ """
49
+ shelving_id: str
50
+ side: Side
51
+ row: int
52
+
53
+ def __str__(self) -> str:
54
+ return f"WarehouseLocation(shelving={self.shelving_id}, side={self.side.name}, row={self.row})"
55
+
56
+
57
+ def new_shelving_id(column: Column, row: Row) -> str:
58
+ """Create a shelving ID from column and row."""
59
+ return f"({column.value},{row.value})"
60
+
61
+
62
+ # Warehouse constants
63
+ SHELVING_WIDTH = 2 # meters
64
+ SHELVING_HEIGHT = 10 # meters
65
+ SHELVING_PADDING = 3 # spacing between shelvings in meters
66
+
67
+ # Initialize static shelving map
68
+ SHELVING_MAP: dict[str, Shelving] = {}
69
+
70
+ def _init_shelving_map():
71
+ """Initialize the warehouse shelving grid."""
72
+ shelving_x = 0
73
+ for col in Column:
74
+ shelving_y = 0
75
+ for row in Row:
76
+ shelving_id = new_shelving_id(col, row)
77
+ SHELVING_MAP[shelving_id] = Shelving(
78
+ id=shelving_id,
79
+ x=shelving_x,
80
+ y=shelving_y
81
+ )
82
+ shelving_y += SHELVING_HEIGHT + SHELVING_PADDING
83
+ shelving_x += SHELVING_WIDTH + SHELVING_PADDING
84
+
85
+ _init_shelving_map()
86
+
87
+
88
+ def get_absolute_x(shelving: Shelving, location: WarehouseLocation) -> int:
89
+ """Calculate absolute X position of a location."""
90
+ if location.side == Side.LEFT:
91
+ return shelving.x
92
+ else:
93
+ return shelving.x + SHELVING_WIDTH
94
+
95
+
96
+ def get_absolute_y(shelving: Shelving, location: WarehouseLocation) -> int:
97
+ """Calculate absolute Y position of a location."""
98
+ return shelving.y + location.row
99
+
100
+
101
+ def calculate_best_y_distance_in_shelving_row(start_row: int, end_row: int) -> int:
102
+ """Calculate the best Y distance when crossing a shelving."""
103
+ north_direction = start_row + end_row
104
+ south_direction = (SHELVING_HEIGHT - start_row) + (SHELVING_HEIGHT - end_row)
105
+ return min(north_direction, south_direction)
106
+
107
+
108
+ def calculate_distance(start: WarehouseLocation, end: WarehouseLocation) -> int:
109
+ """
110
+ Calculate distance in meters between two locations considering warehouse structure.
111
+ """
112
+ start_shelving = SHELVING_MAP.get(start.shelving_id)
113
+ if start_shelving is None:
114
+ raise IndexError(f"Shelving: {start.shelving_id} was not found in current Warehouse structure.")
115
+
116
+ end_shelving = SHELVING_MAP.get(end.shelving_id)
117
+ if end_shelving is None:
118
+ raise IndexError(f"Shelving: {end.shelving_id} was not found in current Warehouse structure.")
119
+
120
+ delta_x = 0
121
+
122
+ start_x = get_absolute_x(start_shelving, start)
123
+ start_y = get_absolute_y(start_shelving, start)
124
+ end_x = get_absolute_x(end_shelving, end)
125
+ end_y = get_absolute_y(end_shelving, end)
126
+
127
+ if start_shelving == end_shelving:
128
+ # Same shelving
129
+ if start.side == end.side:
130
+ # Same side - just vertical distance
131
+ delta_y = abs(start_y - end_y)
132
+ else:
133
+ # Different side - calculate shortest walk around
134
+ delta_x = SHELVING_WIDTH
135
+ delta_y = calculate_best_y_distance_in_shelving_row(start.row, end.row)
136
+ elif start_shelving.y == end_shelving.y:
137
+ # Different shelvings but on same warehouse row
138
+ if abs(start_x - end_x) == SHELVING_PADDING:
139
+ # Neighbor shelvings with contiguous sides
140
+ delta_x = SHELVING_PADDING
141
+ delta_y = abs(start_y - end_y)
142
+ else:
143
+ # Other combinations in same warehouse row
144
+ delta_x = abs(start_x - end_x)
145
+ delta_y = calculate_best_y_distance_in_shelving_row(start.row, end.row)
146
+ else:
147
+ # Shelvings on different warehouse rows
148
+ delta_x = abs(start_x - end_x)
149
+ delta_y = abs(start_y - end_y)
150
+
151
+ return delta_x + delta_y
152
+
153
+
154
+ def calculate_distance_to_travel(trolley: "Trolley") -> int:
155
+ """Calculate total distance a trolley needs to travel through its steps."""
156
+ distance = 0
157
+ previous_location = trolley.location
158
+
159
+ for step in trolley.steps:
160
+ distance += calculate_distance(previous_location, step.location)
161
+ previous_location = step.location
162
+
163
+ # Return trip to origin
164
+ distance += calculate_distance(previous_location, trolley.location)
165
+ return distance
static/app.js ADDED
@@ -0,0 +1,653 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Order Picking Optimization - Frontend Application
3
+ *
4
+ * This application demonstrates real-time constraint optimization for warehouse
5
+ * order picking. It uses SolverForge (a constraint solver) to optimize which
6
+ * trolley picks which items and in what order, minimizing total travel distance
7
+ * while respecting capacity constraints.
8
+ *
9
+ * Architecture:
10
+ * - Backend: FastAPI server with SolverForge solver
11
+ * - Frontend: jQuery + Canvas for visualization
12
+ * - Communication: REST API with 250ms polling for real-time updates
13
+ *
14
+ * Key concepts:
15
+ * - Planning entities: TrolleySteps (items to pick)
16
+ * - Planning variable: Which trolley picks each step
17
+ * - Constraints: Bucket capacity (hard), minimize distance (soft)
18
+ */
19
+
20
+ // =============================================================================
21
+ // Application State
22
+ // =============================================================================
23
+
24
+ /** Current solution data from the solver */
25
+ let loadedSchedule = null;
26
+
27
+ /** Active problem ID when solving (null when not solving) */
28
+ let currentProblemId = null;
29
+
30
+ /** Interval ID for polling updates during solving */
31
+ let autoRefreshIntervalId = null;
32
+
33
+ /** Last score string for detecting improvements */
34
+ let lastScore = null;
35
+
36
+ /** Cached distances per trolley (Map<trolleyId, distance>) */
37
+ let distances = new Map();
38
+
39
+ /** Tracks user intent to solve (prevents race condition with solver startup) */
40
+ let userRequestedSolving = false;
41
+
42
+ // =============================================================================
43
+ // Initialization
44
+ // =============================================================================
45
+
46
+ /**
47
+ * Initialize the application when the DOM is ready.
48
+ * Sets up event handlers and loads initial demo data.
49
+ */
50
+ $(document).ready(function() {
51
+ // Add SolverForge header and footer branding
52
+ replaceQuickstartSolverForgeAutoHeaderFooter();
53
+
54
+ // Initialize the isometric 3D warehouse canvas
55
+ initWarehouseCanvas();
56
+
57
+ // Load default demo data to show something immediately
58
+ loadDemoData();
59
+
60
+ // Wire up button click handlers
61
+ $("#solveButton").click(solve);
62
+ $("#stopSolvingButton").click(stopSolving);
63
+ $("#analyzeButton").click(analyze);
64
+ $("#generateButton").click(generateNewData);
65
+
66
+ // Update displayed values when sliders change
67
+ $("#ordersCountSlider").on("input", function() {
68
+ $("#ordersCountValue").text($(this).val());
69
+ });
70
+ $("#trolleysCountSlider").on("input", function() {
71
+ $("#trolleysCountValue").text($(this).val());
72
+ });
73
+ $("#bucketsCountSlider").on("input", function() {
74
+ $("#bucketsCountValue").text($(this).val());
75
+ });
76
+
77
+ // Redraw warehouse on window resize
78
+ window.addEventListener('resize', () => {
79
+ initWarehouseCanvas();
80
+ if (loadedSchedule) {
81
+ renderWarehouse(loadedSchedule);
82
+ }
83
+ });
84
+ });
85
+
86
+ // =============================================================================
87
+ // Data Loading
88
+ // =============================================================================
89
+
90
+ /**
91
+ * Load the default demo dataset from the server.
92
+ * This provides a starting point with pre-configured orders and trolleys.
93
+ */
94
+ function loadDemoData() {
95
+ fetch('/demo-data/DEFAULT')
96
+ .then(r => r.json())
97
+ .then(solution => {
98
+ loadedSchedule = solution;
99
+ currentProblemId = null;
100
+ updateUI(solution, false);
101
+ })
102
+ .catch(error => {
103
+ showError("Failed to load demo data", error);
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Generate new random demo data based on slider settings.
109
+ * Allows testing with different problem sizes.
110
+ */
111
+ function generateNewData() {
112
+ // Read configuration from UI sliders
113
+ const config = {
114
+ ordersCount: parseInt($("#ordersCountSlider").val()),
115
+ trolleysCount: parseInt($("#trolleysCountSlider").val()),
116
+ bucketCount: parseInt($("#bucketsCountSlider").val())
117
+ };
118
+
119
+ // Show loading state
120
+ const btn = $("#generateButton");
121
+ btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Generating...');
122
+
123
+ fetch('/demo-data/generate', {
124
+ method: 'POST',
125
+ headers: { 'Content-Type': 'application/json' },
126
+ body: JSON.stringify(config)
127
+ })
128
+ .then(r => {
129
+ if (!r.ok) {
130
+ return r.text().then(text => {
131
+ throw new Error(`Server error ${r.status}: ${text}`);
132
+ });
133
+ }
134
+ return r.json();
135
+ })
136
+ .then(solution => {
137
+ loadedSchedule = solution;
138
+ currentProblemId = null;
139
+ distances.clear();
140
+ updateUI(solution, false);
141
+ $("#settingsPanel").collapse('hide');
142
+ showSuccess(`Generated ${config.ordersCount} orders with ${config.trolleysCount} trolleys`);
143
+ })
144
+ .catch(error => {
145
+ console.error("Generate error:", error);
146
+ showError("Failed to generate data: " + error.message, error);
147
+ })
148
+ .finally(() => {
149
+ btn.prop('disabled', false).html('<i class="fas fa-sync-alt"></i> Generate New');
150
+ });
151
+ }
152
+
153
+ // =============================================================================
154
+ // Solving - Start/Stop Optimization
155
+ // =============================================================================
156
+
157
+ /**
158
+ * Start the optimization solver.
159
+ *
160
+ * Flow:
161
+ * 1. POST current schedule to /schedules to start solving
162
+ * 2. Server returns a problemId for tracking
163
+ * 3. Start polling every 250ms for solution updates
164
+ * 4. Update UI in real-time as solver finds better solutions
165
+ */
166
+ function solve() {
167
+ lastScore = null;
168
+ userRequestedSolving = true;
169
+
170
+ // Show solving state immediately for user feedback
171
+ setSolving(true);
172
+
173
+ // Submit the problem to the solver
174
+ fetch('/schedules', {
175
+ method: 'POST',
176
+ headers: { 'Content-Type': 'application/json' },
177
+ body: JSON.stringify(loadedSchedule)
178
+ })
179
+ .then(r => r.text())
180
+ .then(problemId => {
181
+ // Store problem ID for subsequent API calls
182
+ currentProblemId = problemId.replace(/"/g, '');
183
+
184
+ // Start animation from first poll response, not stale loadedSchedule
185
+ ISO.isSolving = true;
186
+
187
+ // Poll for updates every 250ms for smooth real-time visualization
188
+ autoRefreshIntervalId = setInterval(refreshSchedule, 250);
189
+
190
+ // Trigger immediate first poll to get real data ASAP
191
+ refreshSchedule();
192
+ })
193
+ .catch(error => {
194
+ showError("Failed to start solving", error);
195
+ setSolving(false);
196
+ stopWarehouseAnimation();
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Stop the optimization solver early.
202
+ * Returns the best solution found so far.
203
+ */
204
+ function stopSolving() {
205
+ if (!currentProblemId) return;
206
+ userRequestedSolving = false;
207
+
208
+ // Stop polling immediately
209
+ if (autoRefreshIntervalId) {
210
+ clearInterval(autoRefreshIntervalId);
211
+ autoRefreshIntervalId = null;
212
+ }
213
+
214
+ // Update UI immediately - don't wait for server
215
+ setSolving(false);
216
+ stopWarehouseAnimation();
217
+
218
+ // Tell the server to terminate solving
219
+ fetch(`/schedules/${currentProblemId}`, { method: 'DELETE' })
220
+ .then(r => r.ok ? r.json() : Promise.reject(`HTTP ${r.status}`))
221
+ .then(solution => {
222
+ loadedSchedule = solution;
223
+ updateUI(solution, false);
224
+ })
225
+ .catch(error => showError("Failed to stop solving", error));
226
+ }
227
+
228
+ // =============================================================================
229
+ // Real-Time Polling
230
+ // =============================================================================
231
+
232
+ /**
233
+ * Fetch the latest solution and status from the server.
234
+ * Called every 250ms during solving to provide real-time updates.
235
+ *
236
+ * Why polling instead of WebSockets/SSE?
237
+ * - Simpler implementation and debugging
238
+ * - Works reliably across all environments
239
+ * - 250ms is fast enough for smooth visualization
240
+ * - Proven pattern used across SolverForge quickstarts
241
+ */
242
+ function refreshSchedule() {
243
+ if (!currentProblemId) return;
244
+ if (!userRequestedSolving) return; // Don't process if user stopped
245
+
246
+ // Fetch solution and status in parallel for efficiency
247
+ Promise.all([
248
+ fetch(`/schedules/${currentProblemId}`).then(r => r.json()),
249
+ fetch(`/schedules/${currentProblemId}/status`).then(r => r.json())
250
+ ])
251
+ .then(([solution, status]) => {
252
+ // Double-check user hasn't stopped while fetch was in-flight
253
+ if (!userRequestedSolving) return;
254
+
255
+ // CRITICAL: Sync animation state IMMEDIATELY before any rendering
256
+ // This prevents race conditions where animation loop uses stale data
257
+ if (typeof ISO !== 'undefined') {
258
+ ISO.currentSolution = solution;
259
+ }
260
+
261
+ // Update distance cache from status response
262
+ distances = new Map(Object.entries(status.distances || {}));
263
+
264
+ // Detect score improvements and trigger visual feedback
265
+ const newScoreStr = `${status.score.hardScore}hard/${status.score.softScore}soft`;
266
+ if (lastScore && newScoreStr !== lastScore) {
267
+ flashScoreImprovement();
268
+ }
269
+ lastScore = newScoreStr;
270
+
271
+ // Update application state
272
+ loadedSchedule = solution;
273
+ const isSolving = status.solverStatus !== 'NOT_SOLVING' && status.solverStatus != null;
274
+ updateUI(solution, isSolving);
275
+
276
+ // Update animation paths when solution changes
277
+ if (userRequestedSolving) {
278
+ if (ISO.trolleyAnimations.size === 0) {
279
+ startWarehouseAnimation(solution);
280
+ }
281
+ updateWarehouseAnimation(solution);
282
+ }
283
+
284
+ // Auto-stop polling when solver finishes (with valid score) or user stopped
285
+ const solverSaysNotSolving = status.solverStatus === 'NOT_SOLVING';
286
+ const solverActuallyFinished = solverSaysNotSolving && solution.score !== null;
287
+ const shouldStop = !userRequestedSolving || solverActuallyFinished;
288
+
289
+ if (shouldStop) {
290
+ if (autoRefreshIntervalId) {
291
+ clearInterval(autoRefreshIntervalId);
292
+ autoRefreshIntervalId = null;
293
+ }
294
+ userRequestedSolving = false;
295
+ setSolving(false);
296
+ stopWarehouseAnimation();
297
+ }
298
+ })
299
+ .catch(error => {
300
+ console.error("Refresh error:", error);
301
+ });
302
+ }
303
+
304
+ // =============================================================================
305
+ // UI Updates
306
+ // =============================================================================
307
+
308
+ /**
309
+ * Update all UI components with the current solution state.
310
+ *
311
+ * @param {Object} solution - The current solution from the solver
312
+ * @param {boolean} solving - Whether the solver is currently running
313
+ */
314
+ function updateUI(solution, solving) {
315
+ updateScore(solution);
316
+ updateStats(solution);
317
+ updateLegend(solution, distances);
318
+ updateTrolleyCards(solution);
319
+ renderWarehouse(solution);
320
+ setSolving(solving && solution.solverStatus !== 'NOT_SOLVING');
321
+ }
322
+
323
+ /**
324
+ * Update the score display.
325
+ * Score format: "{hardScore}hard/{softScore}soft"
326
+ * - Hard score: Constraint violations (must be 0 for valid solution)
327
+ * - Soft score: Optimization objective (minimize distance)
328
+ */
329
+ function updateScore(solution) {
330
+ const score = solution.score;
331
+ if (!score) {
332
+ $("#score").text("?");
333
+ } else if (typeof score === 'string') {
334
+ $("#score").text(score);
335
+ } else {
336
+ $("#score").text(`${score.hardScore}hard/${score.softScore}soft`);
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Update statistics cards showing problem metrics.
342
+ * Calculates totals from the solution data structure.
343
+ */
344
+ function updateStats(solution) {
345
+ const orderIds = new Set();
346
+ let totalItems = 0;
347
+ let activeTrolleys = 0;
348
+ let totalDistance = 0;
349
+
350
+ // Build lookup for resolving step references
351
+ const stepLookup = new Map();
352
+ for (const step of solution.trolleySteps || []) {
353
+ stepLookup.set(step.id, step);
354
+ }
355
+
356
+ // Aggregate statistics from all trolleys
357
+ for (const trolley of solution.trolleys || []) {
358
+ // Resolve step references (may be IDs or objects)
359
+ const steps = (trolley.steps || []).map(ref =>
360
+ typeof ref === 'string' ? stepLookup.get(ref) : ref
361
+ ).filter(s => s);
362
+
363
+ if (steps.length > 0) {
364
+ activeTrolleys++;
365
+ totalItems += steps.length;
366
+
367
+ // Track unique orders
368
+ for (const step of steps) {
369
+ if (step.orderItem) {
370
+ orderIds.add(step.orderItem.orderId);
371
+ }
372
+ }
373
+ }
374
+
375
+ // Sum distances from cache
376
+ const dist = distances.get(trolley.id) || 0;
377
+ totalDistance += dist;
378
+ }
379
+
380
+ // Update UI with animations
381
+ animateValue("#totalOrders", orderIds.size);
382
+ animateValue("#totalItems", totalItems);
383
+ animateValue("#activeTrolleys", activeTrolleys);
384
+ animateValue("#totalDistance", Math.round(totalDistance / 100)); // cm -> m
385
+ }
386
+
387
+ /**
388
+ * Animate a value change with a brief highlight effect.
389
+ */
390
+ function animateValue(selector, newValue) {
391
+ const el = $(selector);
392
+ const oldValue = parseInt(el.text()) || 0;
393
+ if (oldValue !== newValue) {
394
+ el.text(newValue);
395
+ el.addClass('value-changed');
396
+ setTimeout(() => el.removeClass('value-changed'), 500);
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Render the trolley assignment cards showing items per trolley.
402
+ * Updates in place to avoid flicker.
403
+ */
404
+ function updateTrolleyCards(solution) {
405
+ const container = $("#trolleyCardsContainer");
406
+
407
+ // Build step lookup for reference resolution
408
+ const stepLookup = new Map();
409
+ for (const step of solution.trolleySteps || []) {
410
+ stepLookup.set(step.id, step);
411
+ }
412
+
413
+ const trolleys = solution.trolleys || [];
414
+
415
+ // Create cards if needed (first time or count changed)
416
+ if (container.children().length !== trolleys.length) {
417
+ container.empty();
418
+ for (const trolley of trolleys) {
419
+ const color = getTrolleyColor(trolley.id);
420
+ const card = $(`
421
+ <div class="trolley-card" data-trolley-id="${trolley.id}">
422
+ <div class="trolley-card-header">
423
+ <div class="trolley-color-badge" style="background: ${color}"></div>
424
+ <div class="trolley-card-info">
425
+ <div class="trolley-card-title">Trolley ${trolley.id}</div>
426
+ <div class="trolley-card-stats"></div>
427
+ </div>
428
+ <div class="trolley-capacity-bar">
429
+ <div class="trolley-capacity-fill"></div>
430
+ </div>
431
+ </div>
432
+ <div class="trolley-card-body"></div>
433
+ </div>
434
+ `);
435
+ container.append(card);
436
+ }
437
+ }
438
+
439
+ // Update each card in place
440
+ for (const trolley of trolleys) {
441
+ const card = container.find(`[data-trolley-id="${trolley.id}"]`);
442
+ if (!card.length) continue;
443
+
444
+ const steps = (trolley.steps || []).map(ref =>
445
+ typeof ref === 'string' ? stepLookup.get(ref) : ref
446
+ ).filter(s => s);
447
+
448
+ const itemCount = steps.length;
449
+
450
+ // Calculate capacity
451
+ let totalVolume = 0;
452
+ const bucketCapacity = 50000;
453
+ const bucketCount = trolley.bucketCount || 6;
454
+ const maxCapacity = bucketCapacity * bucketCount;
455
+ for (const step of steps) {
456
+ if (step.orderItem?.product?.volume) {
457
+ totalVolume += step.orderItem.product.volume;
458
+ }
459
+ }
460
+ const capacityPercent = Math.min(100, Math.round((totalVolume / maxCapacity) * 100));
461
+ const capacityClass = capacityPercent > 90 ? 'high' : capacityPercent > 70 ? 'medium' : 'low';
462
+
463
+ // Update stats
464
+ card.find('.trolley-card-stats').text(`${itemCount} items`);
465
+
466
+ // Update capacity bar
467
+ const fill = card.find('.trolley-capacity-fill');
468
+ fill.css('width', `${capacityPercent}%`);
469
+ fill.removeClass('low medium high').addClass(capacityClass);
470
+
471
+ // Update items list
472
+ const body = card.find('.trolley-card-body');
473
+ if (itemCount > 0) {
474
+ body.html(`
475
+ <div class="trolley-items-list">
476
+ ${steps.map((step, i) => `
477
+ <div class="trolley-item">
478
+ <span class="trolley-item-number">${i + 1}</span>
479
+ ${step.orderItem?.product?.name?.substring(0, 15) || 'Item'}
480
+ </div>
481
+ `).join('')}
482
+ </div>
483
+ `);
484
+ } else {
485
+ body.html('<div class="trolley-empty">No items assigned</div>');
486
+ }
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Update UI to reflect solving/not-solving state.
492
+ */
493
+ function setSolving(solving) {
494
+ if (solving) {
495
+ $("#solveButton").hide();
496
+ $("#stopSolvingButton").show();
497
+ $("#solvingIndicator").show();
498
+ $("#generateButton").prop('disabled', true);
499
+ } else {
500
+ $("#solveButton").show();
501
+ $("#stopSolvingButton").hide();
502
+ $("#solvingIndicator").hide();
503
+ $("#generateButton").prop('disabled', false);
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Flash the score display to indicate improvement.
509
+ */
510
+ function flashScoreImprovement() {
511
+ const display = $("#scoreDisplay");
512
+ display.addClass('improved');
513
+ setTimeout(() => display.removeClass('improved'), 500);
514
+ }
515
+
516
+ // =============================================================================
517
+ // Score Analysis
518
+ // =============================================================================
519
+
520
+ /**
521
+ * Fetch and display detailed constraint analysis.
522
+ * Shows which constraints are satisfied/violated and their contribution to score.
523
+ */
524
+ function analyze() {
525
+ if (!currentProblemId) {
526
+ showError("No active solution to analyze");
527
+ return;
528
+ }
529
+
530
+ // Show loading state
531
+ const btn = $("#analyzeButton");
532
+ btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
533
+
534
+ fetch(`/schedules/${currentProblemId}/score-analysis`)
535
+ .then(r => r.json())
536
+ .then(analysis => {
537
+ showScoreAnalysis(analysis);
538
+ })
539
+ .catch(error => {
540
+ showError("Failed to load score analysis", error);
541
+ })
542
+ .finally(() => {
543
+ btn.prop('disabled', false).html('<i class="fas fa-chart-bar"></i>');
544
+ });
545
+ }
546
+
547
+ /**
548
+ * Display score analysis in a modal dialog.
549
+ */
550
+ function showScoreAnalysis(analysis) {
551
+ const content = $("#scoreAnalysisModalContent");
552
+ content.empty();
553
+
554
+ if (!analysis || !analysis.constraints) {
555
+ content.html('<p>No constraint data available.</p>');
556
+ } else {
557
+ for (const constraint of analysis.constraints) {
558
+ const score = constraint.score || '0';
559
+ const isHard = score.includes('hard');
560
+
561
+ const group = $(`
562
+ <div class="constraint-group">
563
+ <div class="constraint-header">
564
+ <span class="constraint-name">${constraint.name}</span>
565
+ <span class="constraint-score ${isHard ? 'hard' : 'soft'}">${score}</span>
566
+ </div>
567
+ </div>
568
+ `);
569
+ content.append(group);
570
+ }
571
+ }
572
+
573
+ // Use getOrCreateInstance to avoid stacking modal instances
574
+ const modalEl = document.getElementById('scoreAnalysisModal');
575
+ bootstrap.Modal.getOrCreateInstance(modalEl).show();
576
+ }
577
+
578
+ // =============================================================================
579
+ // Notifications
580
+ // =============================================================================
581
+
582
+ /**
583
+ * Display an error notification that auto-dismisses.
584
+ */
585
+ function showError(message, error) {
586
+ console.error(message, error);
587
+ const alert = $(`
588
+ <div class="alert alert-danger alert-dismissible fade show">
589
+ <i class="fas fa-exclamation-circle me-2"></i>
590
+ <strong>Error:</strong> ${message}
591
+ <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
592
+ </div>
593
+ `);
594
+ $("#notificationPanel").append(alert);
595
+ setTimeout(() => alert.alert('close'), 5000);
596
+ }
597
+
598
+ /**
599
+ * Display a success notification that auto-dismisses.
600
+ */
601
+ function showSuccess(message) {
602
+ const alert = $(`
603
+ <div class="alert alert-success alert-dismissible fade show">
604
+ <i class="fas fa-check-circle me-2"></i>${message}
605
+ <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
606
+ </div>
607
+ `);
608
+ $("#notificationPanel").append(alert);
609
+ setTimeout(() => alert.alert('close'), 3000);
610
+ }
611
+
612
+ // =============================================================================
613
+ // SolverForge Branding
614
+ // =============================================================================
615
+
616
+ /**
617
+ * Add SolverForge header and footer branding.
618
+ * Matches the pattern used in other SolverForge quickstarts.
619
+ */
620
+ function replaceQuickstartSolverForgeAutoHeaderFooter() {
621
+ const header = $("header#solverforge-auto-header");
622
+ if (header.length) {
623
+ header.css("background-color", "#ffffff");
624
+ header.append($(`
625
+ <div class="container-fluid">
626
+ <nav class="navbar sticky-top navbar-expand-lg shadow-sm mb-3" style="background-color: #ffffff;">
627
+ <a class="navbar-brand" href="https://www.solverforge.org">
628
+ <img src="/webjars/solverforge/img/solverforge-horizontal.svg" alt="SolverForge logo" width="400">
629
+ </a>
630
+ </nav>
631
+ </div>
632
+ `));
633
+ }
634
+
635
+ const footer = $("footer#solverforge-auto-footer");
636
+ if (footer.length) {
637
+ footer.append($(`
638
+ <footer class="bg-black text-white-50">
639
+ <div class="container">
640
+ <div class="hstack gap-3 p-4">
641
+ <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
642
+ <div class="vr"></div>
643
+ <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
644
+ <div class="vr"></div>
645
+ <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
646
+ <div class="vr"></div>
647
+ <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
648
+ </div>
649
+ </div>
650
+ </footer>
651
+ `));
652
+ }
653
+ }
static/index.html ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Order Picking - SolverForge</title>
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"/>
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"/>
9
+ <link rel="stylesheet" href="/webjars/solverforge/css/solverforge-webui.css"/>
10
+ <link rel="stylesheet" href="/style.css"/>
11
+ <link rel="icon" href="/webjars/solverforge/img/solverforge-favicon.svg" type="image/svg+xml">
12
+ </head>
13
+ <body>
14
+ <header id="solverforge-auto-header"></header>
15
+
16
+ <div class="container-fluid main-container">
17
+ <!-- Notification Area -->
18
+ <div class="notification-area">
19
+ <div id="notificationPanel"></div>
20
+ </div>
21
+
22
+ <!-- Header Bar -->
23
+ <div class="header-bar">
24
+ <div class="header-left">
25
+ <h1>Order Picking</h1>
26
+ </div>
27
+ <div class="header-center">
28
+ <button id="solveButton" class="btn btn-solve">
29
+ <i class="fas fa-play"></i> Solve
30
+ </button>
31
+ <button id="stopSolvingButton" class="btn btn-stop" style="display: none">
32
+ <i class="fas fa-stop"></i> Stop
33
+ </button>
34
+ <div id="scoreDisplay" class="score-display">
35
+ <span class="score-label">Score</span>
36
+ <span id="score" class="score-value">?</span>
37
+ </div>
38
+ <div id="solvingIndicator" class="solving-indicator" style="display: none">
39
+ <div class="solving-spinner"></div>
40
+ <span>Optimizing...</span>
41
+ </div>
42
+ </div>
43
+ <div class="header-right">
44
+ <button id="settingsToggle" class="btn btn-settings" data-bs-toggle="collapse" data-bs-target="#settingsPanel">
45
+ <i class="fas fa-sliders-h"></i>
46
+ </button>
47
+ <button id="analyzeButton" class="btn btn-analyze">
48
+ <i class="fas fa-chart-bar"></i>
49
+ </button>
50
+ </div>
51
+ </div>
52
+
53
+ <!-- Settings Panel (Collapsible) -->
54
+ <div class="collapse" id="settingsPanel">
55
+ <div class="settings-panel">
56
+ <div class="settings-grid">
57
+ <div class="setting-item">
58
+ <label>
59
+ <i class="fas fa-shopping-cart"></i> Orders
60
+ <span class="badge" id="ordersCountValue">40</span>
61
+ </label>
62
+ <input type="range" id="ordersCountSlider" min="10" max="100" value="40">
63
+ </div>
64
+ <div class="setting-item">
65
+ <label>
66
+ <i class="fas fa-dolly"></i> Trolleys
67
+ <span class="badge" id="trolleysCountValue">8</span>
68
+ </label>
69
+ <input type="range" id="trolleysCountSlider" min="3" max="15" value="8">
70
+ </div>
71
+ <div class="setting-item">
72
+ <label>
73
+ <i class="fas fa-box"></i> Buckets
74
+ <span class="badge" id="bucketsCountValue">6</span>
75
+ </label>
76
+ <input type="range" id="bucketsCountSlider" min="2" max="10" value="6">
77
+ </div>
78
+ <div class="setting-item">
79
+ <button id="generateButton" class="btn btn-generate">
80
+ <i class="fas fa-sync-alt"></i> Generate New
81
+ </button>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ </div>
86
+
87
+ <!-- Main Content Area -->
88
+ <div class="content-area">
89
+ <!-- Stats Cards -->
90
+ <div class="stats-sidebar">
91
+ <div class="stat-card" id="ordersCard">
92
+ <div class="stat-icon"><i class="fas fa-clipboard-list"></i></div>
93
+ <div class="stat-info">
94
+ <span class="stat-value" id="totalOrders">0</span>
95
+ <span class="stat-label">Orders</span>
96
+ </div>
97
+ </div>
98
+ <div class="stat-card" id="itemsCard">
99
+ <div class="stat-icon"><i class="fas fa-cube"></i></div>
100
+ <div class="stat-info">
101
+ <span class="stat-value" id="totalItems">0</span>
102
+ <span class="stat-label">Items</span>
103
+ </div>
104
+ </div>
105
+ <div class="stat-card" id="trolleysCard">
106
+ <div class="stat-icon"><i class="fas fa-dolly"></i></div>
107
+ <div class="stat-info">
108
+ <span class="stat-value" id="activeTrolleys">0</span>
109
+ <span class="stat-label">Active</span>
110
+ </div>
111
+ </div>
112
+ <div class="stat-card" id="distanceCard">
113
+ <div class="stat-icon"><i class="fas fa-route"></i></div>
114
+ <div class="stat-info">
115
+ <span class="stat-value" id="totalDistance">0</span>
116
+ <span class="stat-label">meters</span>
117
+ </div>
118
+ </div>
119
+ </div>
120
+
121
+ <!-- Canvas Area -->
122
+ <div class="canvas-area">
123
+ <div id="warehouseContainer">
124
+ <canvas id="warehouseCanvas"></canvas>
125
+ <!-- Legend Overlay -->
126
+ <div id="legendOverlay">
127
+ <div class="legend-header">
128
+ <i class="fas fa-dolly"></i> Trolleys
129
+ </div>
130
+ <div id="trolleyLegend">
131
+ <!-- Populated by JS -->
132
+ </div>
133
+ </div>
134
+ </div>
135
+ </div>
136
+ </div>
137
+
138
+ <!-- Trolley Cards Section -->
139
+ <div class="trolley-section">
140
+ <div class="section-header">
141
+ <h2><i class="fas fa-tasks"></i> Picking Plan</h2>
142
+ </div>
143
+ <div id="trolleyCardsContainer" class="trolley-cards-grid">
144
+ <!-- Populated by JS -->
145
+ </div>
146
+ </div>
147
+ </div>
148
+
149
+ <!-- Score Analysis Modal -->
150
+ <div class="modal fade" id="scoreAnalysisModal" tabindex="-1">
151
+ <div class="modal-dialog modal-lg modal-dialog-scrollable">
152
+ <div class="modal-content">
153
+ <div class="modal-header">
154
+ <h5 class="modal-title">
155
+ <i class="fas fa-chart-bar"></i> Score Analysis
156
+ <span id="scoreAnalysisScoreLabel"></span>
157
+ </h5>
158
+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
159
+ </div>
160
+ <div class="modal-body" id="scoreAnalysisModalContent">
161
+ <!-- Populated by JS -->
162
+ </div>
163
+ <div class="modal-footer">
164
+ <button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
165
+ </div>
166
+ </div>
167
+ </div>
168
+ </div>
169
+
170
+ <footer id="solverforge-auto-footer"></footer>
171
+
172
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
173
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script>
174
+ <script src="/webjars/solverforge/js/solverforge-webui.js"></script>
175
+ <script src="/warehouse-api.js"></script>
176
+ <script src="/logisim-view.js"></script>
177
+ <script src="/app.js"></script>
178
+ </body>
179
+ </html>
static/logisim-view.js ADDED
@@ -0,0 +1,850 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Isometric 3D Warehouse Visualization
3
+ * Professional order picking visualization with animated trolleys
4
+ */
5
+
6
+ const ISO = {
7
+ // Isometric projection angles
8
+ ANGLE: Math.PI / 6, // 30 degrees
9
+
10
+ // Tile dimensions
11
+ TILE_WIDTH: 48,
12
+ TILE_HEIGHT: 24,
13
+
14
+ // Shelf dimensions (in tiles)
15
+ SHELF_WIDTH: 3,
16
+ SHELF_DEPTH: 1,
17
+ SHELF_HEIGHT: 2,
18
+
19
+ // Warehouse layout: 5 columns (A-E) x 3 rows
20
+ COLS: 5,
21
+ ROWS: 3,
22
+
23
+ // Spacing between shelves
24
+ AISLE_WIDTH: 2,
25
+
26
+ // Colors
27
+ COLORS: {
28
+ floor: '#e2e8f0',
29
+ floorGrid: '#cbd5e1',
30
+ shelfTop: '#ffffff',
31
+ shelfFront: '#f1f5f9',
32
+ shelfSide: '#e2e8f0',
33
+ shelfBorder: '#94a3b8',
34
+ shadow: 'rgba(0, 0, 0, 0.1)',
35
+ trolley: [
36
+ '#ef4444', // red
37
+ '#3b82f6', // blue
38
+ '#10b981', // green
39
+ '#f59e0b', // amber
40
+ '#8b5cf6', // purple
41
+ '#06b6d4', // cyan
42
+ '#ec4899', // pink
43
+ '#84cc16', // lime
44
+ ],
45
+ path: 'rgba(59, 130, 246, 0.3)',
46
+ pathActive: 'rgba(59, 130, 246, 0.6)',
47
+ },
48
+
49
+ // Animation state
50
+ animationId: null,
51
+ isSolving: false,
52
+ trolleyAnimations: new Map(),
53
+ currentSolution: null,
54
+
55
+ // Canvas state
56
+ canvas: null,
57
+ ctx: null,
58
+ dpr: 1,
59
+ width: 0,
60
+ height: 0,
61
+ originX: 0,
62
+ originY: 0,
63
+ // Grid offset to center the warehouse
64
+ gridOffsetX: 0,
65
+ gridOffsetY: 0,
66
+ };
67
+
68
+ // Column/Row mapping
69
+ const COLUMNS = ['A', 'B', 'C', 'D', 'E'];
70
+ const ROWS = ['1', '2', '3'];
71
+
72
+ /**
73
+ * Convert isometric coordinates to screen coordinates
74
+ */
75
+ function isoToScreen(x, y, z = 0) {
76
+ // Apply grid offset to center the warehouse
77
+ const centeredX = x - ISO.gridOffsetX;
78
+ const centeredY = y - ISO.gridOffsetY;
79
+ const screenX = ISO.originX + (centeredX - centeredY) * (ISO.TILE_WIDTH / 2);
80
+ const screenY = ISO.originY + (centeredX + centeredY) * (ISO.TILE_HEIGHT / 2) - z * ISO.TILE_HEIGHT;
81
+ return { x: screenX, y: screenY };
82
+ }
83
+
84
+ /**
85
+ * Get trolley color by ID
86
+ */
87
+ function getTrolleyColor(trolleyId) {
88
+ const index = (parseInt(trolleyId) - 1) % ISO.COLORS.trolley.length;
89
+ return ISO.COLORS.trolley[index];
90
+ }
91
+
92
+ /**
93
+ * Initialize the canvas
94
+ */
95
+ function initWarehouseCanvas() {
96
+ const container = document.getElementById('warehouseContainer');
97
+ if (!container) return;
98
+
99
+ let canvas = document.getElementById('warehouseCanvas');
100
+ if (!canvas) {
101
+ canvas = document.createElement('canvas');
102
+ canvas.id = 'warehouseCanvas';
103
+ container.appendChild(canvas);
104
+ }
105
+
106
+ ISO.canvas = canvas;
107
+ ISO.ctx = canvas.getContext('2d');
108
+ ISO.dpr = window.devicePixelRatio || 1;
109
+
110
+ // Calculate canvas size based on warehouse dimensions
111
+ const totalWidth = (ISO.COLS * (ISO.SHELF_WIDTH + ISO.AISLE_WIDTH) + ISO.AISLE_WIDTH) * ISO.TILE_WIDTH;
112
+ const totalHeight = (ISO.ROWS * (ISO.SHELF_DEPTH + ISO.AISLE_WIDTH) + ISO.AISLE_WIDTH) * ISO.TILE_WIDTH;
113
+
114
+ // Isometric dimensions
115
+ ISO.width = totalWidth + 200;
116
+ ISO.height = totalHeight / 2 + 300;
117
+
118
+ // Set canvas size with HiDPI support
119
+ canvas.width = ISO.width * ISO.dpr;
120
+ canvas.height = ISO.height * ISO.dpr;
121
+ canvas.style.width = ISO.width + 'px';
122
+ canvas.style.height = ISO.height + 'px';
123
+
124
+ ISO.ctx.scale(ISO.dpr, ISO.dpr);
125
+
126
+ // Calculate grid center offset for centering
127
+ const gridSize = ISO.COLS * (ISO.SHELF_WIDTH + ISO.AISLE_WIDTH) + ISO.AISLE_WIDTH;
128
+ const gridDepth = ISO.ROWS * (ISO.SHELF_DEPTH + ISO.AISLE_WIDTH) + ISO.AISLE_WIDTH;
129
+ ISO.gridOffsetX = gridSize / 2;
130
+ ISO.gridOffsetY = gridDepth / 2;
131
+
132
+ // Set origin point (center of canvas)
133
+ ISO.originX = ISO.width / 2;
134
+ ISO.originY = ISO.height / 2 - 50; // Slightly above center
135
+ }
136
+
137
+ /**
138
+ * Draw the warehouse floor grid
139
+ */
140
+ function drawFloor() {
141
+ const ctx = ISO.ctx;
142
+ const gridSize = ISO.COLS * (ISO.SHELF_WIDTH + ISO.AISLE_WIDTH) + ISO.AISLE_WIDTH;
143
+ const gridDepth = ISO.ROWS * (ISO.SHELF_DEPTH + ISO.AISLE_WIDTH) + ISO.AISLE_WIDTH;
144
+
145
+ // Draw floor tiles
146
+ for (let x = 0; x < gridSize; x++) {
147
+ for (let y = 0; y < gridDepth; y++) {
148
+ const p1 = isoToScreen(x, y);
149
+ const p2 = isoToScreen(x + 1, y);
150
+ const p3 = isoToScreen(x + 1, y + 1);
151
+ const p4 = isoToScreen(x, y + 1);
152
+
153
+ ctx.beginPath();
154
+ ctx.moveTo(p1.x, p1.y);
155
+ ctx.lineTo(p2.x, p2.y);
156
+ ctx.lineTo(p3.x, p3.y);
157
+ ctx.lineTo(p4.x, p4.y);
158
+ ctx.closePath();
159
+
160
+ ctx.fillStyle = ISO.COLORS.floor;
161
+ ctx.fill();
162
+ ctx.strokeStyle = ISO.COLORS.floorGrid;
163
+ ctx.lineWidth = 0.5;
164
+ ctx.stroke();
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Draw a 3D shelf at grid position
171
+ */
172
+ function drawShelf(col, row, label) {
173
+ const ctx = ISO.ctx;
174
+
175
+ // Calculate grid position
176
+ const gridX = ISO.AISLE_WIDTH + col * (ISO.SHELF_WIDTH + ISO.AISLE_WIDTH);
177
+ const gridY = ISO.AISLE_WIDTH + row * (ISO.SHELF_DEPTH + ISO.AISLE_WIDTH);
178
+
179
+ const w = ISO.SHELF_WIDTH;
180
+ const d = ISO.SHELF_DEPTH;
181
+ const h = ISO.SHELF_HEIGHT;
182
+
183
+ // Get corner points
184
+ const topFront = [
185
+ isoToScreen(gridX, gridY + d, h),
186
+ isoToScreen(gridX + w, gridY + d, h),
187
+ isoToScreen(gridX + w, gridY, h),
188
+ isoToScreen(gridX, gridY, h),
189
+ ];
190
+
191
+ const bottomFront = [
192
+ isoToScreen(gridX, gridY + d, 0),
193
+ isoToScreen(gridX + w, gridY + d, 0),
194
+ ];
195
+
196
+ const bottomSide = [
197
+ isoToScreen(gridX + w, gridY, 0),
198
+ ];
199
+
200
+ // Draw shadow
201
+ ctx.beginPath();
202
+ const shadowOffset = 0.3;
203
+ const s1 = isoToScreen(gridX + shadowOffset, gridY + d + shadowOffset, 0);
204
+ const s2 = isoToScreen(gridX + w + shadowOffset, gridY + d + shadowOffset, 0);
205
+ const s3 = isoToScreen(gridX + w + shadowOffset, gridY + shadowOffset, 0);
206
+ const s4 = isoToScreen(gridX + shadowOffset, gridY + shadowOffset, 0);
207
+ ctx.moveTo(s1.x, s1.y);
208
+ ctx.lineTo(s2.x, s2.y);
209
+ ctx.lineTo(s3.x, s3.y);
210
+ ctx.lineTo(s4.x, s4.y);
211
+ ctx.closePath();
212
+ ctx.fillStyle = ISO.COLORS.shadow;
213
+ ctx.fill();
214
+
215
+ // Draw front face
216
+ ctx.beginPath();
217
+ ctx.moveTo(topFront[0].x, topFront[0].y);
218
+ ctx.lineTo(topFront[1].x, topFront[1].y);
219
+ ctx.lineTo(bottomFront[1].x, bottomFront[1].y);
220
+ ctx.lineTo(bottomFront[0].x, bottomFront[0].y);
221
+ ctx.closePath();
222
+ ctx.fillStyle = ISO.COLORS.shelfFront;
223
+ ctx.fill();
224
+ ctx.strokeStyle = ISO.COLORS.shelfBorder;
225
+ ctx.lineWidth = 1;
226
+ ctx.stroke();
227
+
228
+ // Draw side face
229
+ ctx.beginPath();
230
+ ctx.moveTo(topFront[1].x, topFront[1].y);
231
+ ctx.lineTo(topFront[2].x, topFront[2].y);
232
+ ctx.lineTo(bottomSide[0].x, bottomSide[0].y);
233
+ ctx.lineTo(bottomFront[1].x, bottomFront[1].y);
234
+ ctx.closePath();
235
+ ctx.fillStyle = ISO.COLORS.shelfSide;
236
+ ctx.fill();
237
+ ctx.strokeStyle = ISO.COLORS.shelfBorder;
238
+ ctx.stroke();
239
+
240
+ // Draw top face
241
+ ctx.beginPath();
242
+ ctx.moveTo(topFront[0].x, topFront[0].y);
243
+ ctx.lineTo(topFront[1].x, topFront[1].y);
244
+ ctx.lineTo(topFront[2].x, topFront[2].y);
245
+ ctx.lineTo(topFront[3].x, topFront[3].y);
246
+ ctx.closePath();
247
+ ctx.fillStyle = ISO.COLORS.shelfTop;
248
+ ctx.fill();
249
+ ctx.strokeStyle = ISO.COLORS.shelfBorder;
250
+ ctx.stroke();
251
+
252
+ // Draw label
253
+ const centerX = (topFront[0].x + topFront[1].x + topFront[2].x + topFront[3].x) / 4;
254
+ const centerY = (topFront[0].y + topFront[1].y + topFront[2].y + topFront[3].y) / 4;
255
+
256
+ ctx.font = 'bold 14px -apple-system, BlinkMacSystemFont, sans-serif';
257
+ ctx.textAlign = 'center';
258
+ ctx.textBaseline = 'middle';
259
+ ctx.fillStyle = '#475569';
260
+ ctx.fillText(label, centerX, centerY);
261
+ }
262
+
263
+ /**
264
+ * Draw all shelves
265
+ */
266
+ function drawShelves() {
267
+ for (let col = 0; col < ISO.COLS; col++) {
268
+ for (let row = 0; row < ISO.ROWS; row++) {
269
+ const label = COLUMNS[col] + ',' + ROWS[row];
270
+ drawShelf(col, row, label);
271
+ }
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Draw a trolley at position
277
+ */
278
+ function drawTrolley(x, y, color, trolleyId, progress = 1) {
279
+ const ctx = ISO.ctx;
280
+ const pos = isoToScreen(x, y, 0.3);
281
+
282
+ // Trolley body dimensions
283
+ const bodyWidth = 20;
284
+ const bodyHeight = 14;
285
+ const bodyDepth = 8;
286
+
287
+ // Draw trolley body (isometric box)
288
+ ctx.save();
289
+ ctx.translate(pos.x, pos.y);
290
+
291
+ // Shadow
292
+ ctx.beginPath();
293
+ ctx.ellipse(0, 6, 12, 6, 0, 0, Math.PI * 2);
294
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
295
+ ctx.fill();
296
+
297
+ // Body - front
298
+ ctx.beginPath();
299
+ ctx.moveTo(-bodyWidth/2, -bodyDepth);
300
+ ctx.lineTo(bodyWidth/2, -bodyDepth);
301
+ ctx.lineTo(bodyWidth/2, bodyHeight - bodyDepth);
302
+ ctx.lineTo(-bodyWidth/2, bodyHeight - bodyDepth);
303
+ ctx.closePath();
304
+ ctx.fillStyle = color;
305
+ ctx.fill();
306
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)';
307
+ ctx.lineWidth = 1;
308
+ ctx.stroke();
309
+
310
+ // Body - top
311
+ ctx.beginPath();
312
+ ctx.moveTo(-bodyWidth/2, -bodyDepth);
313
+ ctx.lineTo(0, -bodyDepth - 6);
314
+ ctx.lineTo(bodyWidth/2, -bodyDepth);
315
+ ctx.lineTo(0, -bodyDepth + 3);
316
+ ctx.closePath();
317
+ const lighterColor = lightenColor(color, 20);
318
+ ctx.fillStyle = lighterColor;
319
+ ctx.fill();
320
+ ctx.stroke();
321
+
322
+ // Handle
323
+ ctx.beginPath();
324
+ ctx.moveTo(-bodyWidth/2 + 3, -bodyDepth - 2);
325
+ ctx.lineTo(-bodyWidth/2 + 3, -bodyDepth - 12);
326
+ ctx.lineTo(bodyWidth/2 - 3, -bodyDepth - 12);
327
+ ctx.lineTo(bodyWidth/2 - 3, -bodyDepth - 2);
328
+ ctx.strokeStyle = darkenColor(color, 20);
329
+ ctx.lineWidth = 2;
330
+ ctx.stroke();
331
+
332
+ // Trolley number badge
333
+ ctx.beginPath();
334
+ ctx.arc(0, -bodyDepth - 16, 10, 0, Math.PI * 2);
335
+ ctx.fillStyle = 'white';
336
+ ctx.fill();
337
+ ctx.strokeStyle = color;
338
+ ctx.lineWidth = 2;
339
+ ctx.stroke();
340
+
341
+ ctx.font = 'bold 11px -apple-system, sans-serif';
342
+ ctx.textAlign = 'center';
343
+ ctx.textBaseline = 'middle';
344
+ ctx.fillStyle = color;
345
+ ctx.fillText(trolleyId, 0, -bodyDepth - 16);
346
+
347
+ ctx.restore();
348
+ }
349
+
350
+ /**
351
+ * Lighten a hex color
352
+ */
353
+ function lightenColor(hex, percent) {
354
+ const num = parseInt(hex.slice(1), 16);
355
+ const amt = Math.round(2.55 * percent);
356
+ const R = Math.min(255, (num >> 16) + amt);
357
+ const G = Math.min(255, ((num >> 8) & 0x00FF) + amt);
358
+ const B = Math.min(255, (num & 0x0000FF) + amt);
359
+ return '#' + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1);
360
+ }
361
+
362
+ /**
363
+ * Darken a hex color
364
+ */
365
+ function darkenColor(hex, percent) {
366
+ const num = parseInt(hex.slice(1), 16);
367
+ const amt = Math.round(2.55 * percent);
368
+ const R = Math.max(0, (num >> 16) - amt);
369
+ const G = Math.max(0, ((num >> 8) & 0x00FF) - amt);
370
+ const B = Math.max(0, (num & 0x0000FF) - amt);
371
+ return '#' + (0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1);
372
+ }
373
+
374
+ /**
375
+ * Convert warehouse location to grid position
376
+ * Returns position in aisle next to the shelf
377
+ */
378
+ function locationToGrid(location) {
379
+ if (!location) return { x: 1, y: 1 };
380
+
381
+ // Parse shelving ID like "(A, 1)"
382
+ const shelvingId = location.shelvingId || '';
383
+ const match = shelvingId.match(/\(([A-E]),\s*(\d)\)/);
384
+
385
+ let col = 0, row = 0;
386
+ if (match) {
387
+ col = COLUMNS.indexOf(match[1]);
388
+ row = parseInt(match[2]) - 1;
389
+ }
390
+
391
+ // Calculate shelf's grid position
392
+ const shelfGridX = ISO.AISLE_WIDTH + col * (ISO.SHELF_WIDTH + ISO.AISLE_WIDTH);
393
+ const shelfGridY = ISO.AISLE_WIDTH + row * (ISO.SHELF_DEPTH + ISO.AISLE_WIDTH);
394
+
395
+ // Position in the aisle in front of the shelf (below it in grid terms)
396
+ const aisleY = shelfGridY + ISO.SHELF_DEPTH + 0.5;
397
+
398
+ // Adjust X for side (LEFT/RIGHT of shelf)
399
+ const side = location.side;
400
+ let gridX;
401
+ if (side === 'LEFT') {
402
+ gridX = shelfGridX - 0.5; // Left aisle
403
+ } else {
404
+ gridX = shelfGridX + ISO.SHELF_WIDTH + 0.5; // Right aisle
405
+ }
406
+
407
+ return { x: gridX, y: aisleY };
408
+ }
409
+
410
+ /**
411
+ * Get the main aisle Y position (horizontal corridor)
412
+ */
413
+ function getMainAisleY() {
414
+ // Main aisle runs at the bottom of the warehouse
415
+ return (ISO.ROWS * (ISO.SHELF_DEPTH + ISO.AISLE_WIDTH)) + ISO.AISLE_WIDTH + 0.5;
416
+ }
417
+
418
+ /**
419
+ * Get vertical aisle X positions (between shelves)
420
+ */
421
+ function getVerticalAisleX(col) {
422
+ // Aisle to the left of column 'col'
423
+ return ISO.AISLE_WIDTH + col * (ISO.SHELF_WIDTH + ISO.AISLE_WIDTH) - 0.5;
424
+ }
425
+
426
+ /**
427
+ * Build a path that follows aisles from start to end
428
+ * Uses a simple strategy: go to main aisle, traverse horizontally, go up to destination
429
+ */
430
+ function buildAislePath(start, end) {
431
+ const path = [start];
432
+
433
+ // If start and end are very close, just go directly
434
+ const dx = Math.abs(start.x - end.x);
435
+ const dy = Math.abs(start.y - end.y);
436
+ if (dx < 2 && dy < 2) {
437
+ path.push(end);
438
+ return path;
439
+ }
440
+
441
+ const mainAisleY = getMainAisleY();
442
+
443
+ // Strategy: go down to main aisle (or near it), traverse, then go up
444
+ // First, go to the nearest vertical aisle
445
+ const startAisleY = Math.max(start.y, mainAisleY - 1);
446
+ const endAisleY = Math.max(end.y, mainAisleY - 1);
447
+
448
+ // Move to horizontal travel position
449
+ if (Math.abs(start.y - startAisleY) > 0.5) {
450
+ path.push({ x: start.x, y: startAisleY });
451
+ }
452
+
453
+ // Move horizontally to align with destination column
454
+ if (Math.abs(start.x - end.x) > 0.5) {
455
+ path.push({ x: end.x, y: startAisleY });
456
+ }
457
+
458
+ // Move vertically to destination
459
+ if (Math.abs(path[path.length - 1].y - end.y) > 0.5) {
460
+ path.push({ x: end.x, y: end.y });
461
+ }
462
+
463
+ // Add final destination if different from last point
464
+ const last = path[path.length - 1];
465
+ if (Math.abs(last.x - end.x) > 0.1 || Math.abs(last.y - end.y) > 0.1) {
466
+ path.push(end);
467
+ }
468
+
469
+ return path;
470
+ }
471
+
472
+ /**
473
+ * Build trolley path from steps with proper aisle routing
474
+ * Returns { path: [], pickupIndices: [] }
475
+ */
476
+ function buildTrolleyPath(trolley, steps) {
477
+ const waypoints = [];
478
+ const waypointTypes = []; // 'start', 'pickup', 'end'
479
+
480
+ // Start position
481
+ if (trolley.location) {
482
+ waypoints.push(locationToGrid(trolley.location));
483
+ waypointTypes.push('start');
484
+ }
485
+
486
+ // Add each step location
487
+ for (const step of steps) {
488
+ if (step.orderItem && step.orderItem.product && step.orderItem.product.location) {
489
+ waypoints.push(locationToGrid(step.orderItem.product.location));
490
+ waypointTypes.push('pickup');
491
+ }
492
+ }
493
+
494
+ // Return to start
495
+ if (waypoints.length > 1 && trolley.location) {
496
+ waypoints.push(locationToGrid(trolley.location));
497
+ waypointTypes.push('end');
498
+ }
499
+
500
+ if (waypoints.length <= 1) {
501
+ return { path: waypoints, pickupIndices: [] };
502
+ }
503
+
504
+ // Build full path with aisle routing between each waypoint
505
+ const fullPath = [waypoints[0]];
506
+ const pickupIndices = [];
507
+
508
+ for (let i = 1; i < waypoints.length; i++) {
509
+ const segmentPath = buildAislePath(waypoints[i - 1], waypoints[i]);
510
+ // Skip first point (it's the same as last point of previous segment)
511
+ for (let j = 1; j < segmentPath.length; j++) {
512
+ fullPath.push(segmentPath[j]);
513
+ }
514
+ // Track pickup point index
515
+ if (waypointTypes[i] === 'pickup') {
516
+ pickupIndices.push(fullPath.length - 1);
517
+ }
518
+ }
519
+
520
+ return { path: fullPath, pickupIndices: pickupIndices };
521
+ }
522
+
523
+ /**
524
+ * Draw trolley path
525
+ */
526
+ function drawPath(path, color, pickupIndices, active = false) {
527
+ if (path.length < 2) return;
528
+
529
+ const ctx = ISO.ctx;
530
+ ctx.beginPath();
531
+
532
+ const start = isoToScreen(path[0].x, path[0].y, 0.1);
533
+ ctx.moveTo(start.x, start.y);
534
+
535
+ for (let i = 1; i < path.length; i++) {
536
+ const point = isoToScreen(path[i].x, path[i].y, 0.1);
537
+ ctx.lineTo(point.x, point.y);
538
+ }
539
+
540
+ ctx.strokeStyle = active ? ISO.COLORS.pathActive : ISO.COLORS.path;
541
+ ctx.lineWidth = active ? 4 : 2;
542
+ ctx.lineCap = 'round';
543
+ ctx.lineJoin = 'round';
544
+ ctx.stroke();
545
+
546
+ // Draw pickup markers only at actual pickup points
547
+ let pickupNum = 1;
548
+ for (const idx of pickupIndices) {
549
+ if (idx >= 0 && idx < path.length) {
550
+ const point = isoToScreen(path[idx].x, path[idx].y, 0.5);
551
+
552
+ ctx.beginPath();
553
+ ctx.arc(point.x, point.y, 8, 0, Math.PI * 2);
554
+ ctx.fillStyle = color;
555
+ ctx.fill();
556
+ ctx.strokeStyle = 'white';
557
+ ctx.lineWidth = 2;
558
+ ctx.stroke();
559
+
560
+ ctx.font = 'bold 9px -apple-system, sans-serif';
561
+ ctx.textAlign = 'center';
562
+ ctx.textBaseline = 'middle';
563
+ ctx.fillStyle = 'white';
564
+ ctx.fillText(pickupNum.toString(), point.x, point.y);
565
+ pickupNum++;
566
+ }
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Get position along path at progress (0-1)
572
+ */
573
+ function getPositionOnPath(path, progress) {
574
+ if (!path || path.length === 0) return { x: 0, y: 0 };
575
+ if (path.length === 1) return path[0];
576
+
577
+ const totalSegments = path.length - 1;
578
+ const segmentProgress = progress * totalSegments;
579
+ const currentSegment = Math.min(Math.floor(segmentProgress), totalSegments - 1);
580
+ const segmentT = segmentProgress - currentSegment;
581
+
582
+ const start = path[currentSegment];
583
+ const end = path[currentSegment + 1];
584
+
585
+ // Safety check for undefined points
586
+ if (!start || !end) return path[0] || { x: 0, y: 0 };
587
+
588
+ return {
589
+ x: start.x + (end.x - start.x) * segmentT,
590
+ y: start.y + (end.y - start.y) * segmentT,
591
+ };
592
+ }
593
+
594
+ /**
595
+ * Render the full warehouse scene
596
+ */
597
+ function renderWarehouse(solution) {
598
+ if (!ISO.ctx) return;
599
+
600
+ const ctx = ISO.ctx;
601
+ ctx.clearRect(0, 0, ISO.width, ISO.height);
602
+
603
+ // Draw floor
604
+ drawFloor();
605
+
606
+ // Draw shelves (back to front for proper overlap)
607
+ drawShelves();
608
+
609
+ if (!solution || !solution.trolleys) return;
610
+
611
+ // Build step lookup
612
+ const stepLookup = new Map();
613
+ for (const step of solution.trolleySteps || []) {
614
+ stepLookup.set(step.id, step);
615
+ }
616
+
617
+ // Draw paths and trolleys
618
+ const trolleys = solution.trolleys || [];
619
+
620
+ for (const trolley of trolleys) {
621
+ const steps = (trolley.steps || []).map(ref =>
622
+ typeof ref === 'string' ? stepLookup.get(ref) : ref
623
+ ).filter(s => s);
624
+
625
+ const color = getTrolleyColor(trolley.id);
626
+ const pathData = buildTrolleyPath(trolley, steps);
627
+ const path = pathData.path;
628
+ const pickupIndices = pathData.pickupIndices;
629
+
630
+ // Draw path
631
+ if (path.length > 1) {
632
+ drawPath(path, color, pickupIndices, ISO.isSolving);
633
+ }
634
+
635
+ // Draw trolley
636
+ const anim = ISO.trolleyAnimations.get(trolley.id);
637
+ let pos;
638
+
639
+ if (anim && ISO.isSolving && path.length > 1) {
640
+ const now = Date.now();
641
+ const elapsed = now - anim.startTime;
642
+ const progress = (elapsed % anim.duration) / anim.duration;
643
+ pos = getPositionOnPath(path, progress);
644
+ } else if (path.length > 0) {
645
+ pos = path[0];
646
+ } else {
647
+ pos = locationToGrid(trolley.location);
648
+ }
649
+
650
+ if (pos) {
651
+ drawTrolley(pos.x, pos.y, color, trolley.id);
652
+ }
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Animation loop - only runs when solving to animate trolley positions
658
+ */
659
+ function animate() {
660
+ if (!ISO.isSolving) {
661
+ ISO.animationId = null;
662
+ return;
663
+ }
664
+
665
+ // Only re-render if we have a valid solution
666
+ // The animation loop provides smooth trolley movement
667
+ if (ISO.currentSolution && ISO.currentSolution.trolleys) {
668
+ renderWarehouse(ISO.currentSolution);
669
+ }
670
+
671
+ ISO.animationId = requestAnimationFrame(animate);
672
+ }
673
+
674
+ /**
675
+ * Start solving animation
676
+ */
677
+ function startWarehouseAnimation(solution) {
678
+ ISO.isSolving = true;
679
+ ISO.currentSolution = solution;
680
+ ISO.trolleyAnimations.clear();
681
+
682
+ // Initialize animations for each trolley
683
+ const stepLookup = new Map();
684
+ for (const step of solution.trolleySteps || []) {
685
+ stepLookup.set(step.id, step);
686
+ }
687
+
688
+ for (const trolley of solution.trolleys || []) {
689
+ // Get step IDs for signature
690
+ const stepIds = (trolley.steps || []).map(ref =>
691
+ typeof ref === 'string' ? ref : ref.id
692
+ );
693
+
694
+ const steps = stepIds.map(id => stepLookup.get(id)).filter(s => s);
695
+
696
+ const pathData = buildTrolleyPath(trolley, steps);
697
+ const path = pathData.path;
698
+ const duration = Math.max(3000, path.length * 400);
699
+
700
+ ISO.trolleyAnimations.set(trolley.id, {
701
+ startTime: Date.now() + parseInt(trolley.id) * 200,
702
+ duration: duration,
703
+ path: path,
704
+ stepSignature: stepIds.join(','), // Track initial signature
705
+ });
706
+ }
707
+
708
+ if (!ISO.animationId) {
709
+ animate();
710
+ }
711
+ }
712
+
713
+ /**
714
+ * Update animation with new solution data
715
+ */
716
+ function updateWarehouseAnimation(solution) {
717
+ console.log('[updateWarehouseAnimation] Called with', solution?.trolleys?.length, 'trolleys');
718
+ ISO.currentSolution = solution;
719
+
720
+ // Build step lookup for resolving references
721
+ const stepLookup = new Map();
722
+ for (const step of solution.trolleySteps || []) {
723
+ stepLookup.set(step.id, step);
724
+ }
725
+
726
+ let anyPathChanged = false;
727
+
728
+ for (const trolley of solution.trolleys || []) {
729
+ // Get step IDs for this trolley (for change detection)
730
+ const stepIds = (trolley.steps || []).map(ref =>
731
+ typeof ref === 'string' ? ref : ref.id
732
+ );
733
+
734
+ // Resolve to full step objects
735
+ const steps = stepIds.map(id => stepLookup.get(id)).filter(s => s);
736
+
737
+ const pathData = buildTrolleyPath(trolley, steps);
738
+ const path = pathData.path;
739
+ const existingAnim = ISO.trolleyAnimations.get(trolley.id);
740
+
741
+ if (existingAnim) {
742
+ // Create signature of step assignments to detect any change
743
+ const oldSignature = existingAnim.stepSignature || '';
744
+ const newSignature = stepIds.join(',');
745
+ const pathChanged = oldSignature !== newSignature;
746
+
747
+ if (pathChanged) {
748
+ anyPathChanged = true;
749
+ console.log(`[PATH CHANGE] Trolley ${trolley.id}:`, {
750
+ old: oldSignature.substring(0, 50),
751
+ new: newSignature.substring(0, 50),
752
+ oldLen: oldSignature.split(',').filter(x=>x).length,
753
+ newLen: stepIds.length
754
+ });
755
+ }
756
+
757
+ existingAnim.path = path;
758
+ existingAnim.stepSignature = newSignature;
759
+ existingAnim.duration = Math.max(3000, path.length * 400);
760
+
761
+ // Reset animation timing when path changes to prevent position jumps
762
+ if (pathChanged) {
763
+ existingAnim.startTime = Date.now();
764
+ }
765
+ } else {
766
+ ISO.trolleyAnimations.set(trolley.id, {
767
+ startTime: Date.now(),
768
+ duration: Math.max(3000, path.length * 400),
769
+ path: path,
770
+ stepSignature: stepIds.join(','),
771
+ });
772
+ anyPathChanged = true;
773
+ }
774
+ }
775
+
776
+ // Visual feedback when paths change
777
+ if (anyPathChanged) {
778
+ console.log('[RENDER] Paths changed, forcing immediate render');
779
+ // Flash the canvas border to indicate update
780
+ if (ISO.canvas) {
781
+ ISO.canvas.style.outline = '3px solid #10b981';
782
+ setTimeout(() => { ISO.canvas.style.outline = 'none'; }, 200);
783
+ }
784
+ }
785
+
786
+ // Force immediate render with updated paths
787
+ renderWarehouse(solution);
788
+
789
+ // Restart animation loop if it died
790
+ if (ISO.isSolving && !ISO.animationId) {
791
+ animate();
792
+ }
793
+ }
794
+
795
+ /**
796
+ * Stop animation
797
+ */
798
+ function stopWarehouseAnimation() {
799
+ ISO.isSolving = false;
800
+ if (ISO.animationId) {
801
+ cancelAnimationFrame(ISO.animationId);
802
+ ISO.animationId = null;
803
+ }
804
+ // Render final state
805
+ if (ISO.currentSolution) {
806
+ renderWarehouse(ISO.currentSolution);
807
+ }
808
+ }
809
+
810
+ /**
811
+ * Update legend with trolley info
812
+ */
813
+ function updateLegend(solution, distances) {
814
+ const container = document.getElementById('trolleyLegend');
815
+ if (!container) return;
816
+
817
+ container.innerHTML = '';
818
+
819
+ const stepLookup = new Map();
820
+ for (const step of solution.trolleySteps || []) {
821
+ stepLookup.set(step.id, step);
822
+ }
823
+
824
+ for (const trolley of solution.trolleys || []) {
825
+ const steps = (trolley.steps || []).map(ref =>
826
+ typeof ref === 'string' ? stepLookup.get(ref) : ref
827
+ ).filter(s => s);
828
+
829
+ const color = getTrolleyColor(trolley.id);
830
+ const distance = distances ? distances.get(trolley.id) || 0 : 0;
831
+
832
+ const item = document.createElement('div');
833
+ item.className = 'legend-item';
834
+ item.innerHTML = `
835
+ <div class="legend-color" style="background: ${color}"></div>
836
+ <span class="legend-text">Trolley ${trolley.id}</span>
837
+ <span class="legend-distance">${steps.length} items</span>
838
+ `;
839
+ container.appendChild(item);
840
+ }
841
+ }
842
+
843
+ // Export for app.js
844
+ window.initWarehouseCanvas = initWarehouseCanvas;
845
+ window.renderWarehouse = renderWarehouse;
846
+ window.startWarehouseAnimation = startWarehouseAnimation;
847
+ window.updateWarehouseAnimation = updateWarehouseAnimation;
848
+ window.stopWarehouseAnimation = stopWarehouseAnimation;
849
+ window.updateLegend = updateLegend;
850
+ window.getTrolleyColor = getTrolleyColor;
static/style.css ADDED
@@ -0,0 +1,810 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =============================================================================
2
+ Order Picking - Professional UI Styles
3
+ ============================================================================= */
4
+
5
+ :root {
6
+ --primary: #10b981;
7
+ --primary-dark: #059669;
8
+ --primary-light: #34d399;
9
+ --danger: #ef4444;
10
+ --warning: #f59e0b;
11
+ --info: #3b82f6;
12
+ --dark: #1e293b;
13
+ --gray-50: #f8fafc;
14
+ --gray-100: #f1f5f9;
15
+ --gray-200: #e2e8f0;
16
+ --gray-300: #cbd5e1;
17
+ --gray-400: #94a3b8;
18
+ --gray-500: #64748b;
19
+ --gray-600: #475569;
20
+ --gray-700: #334155;
21
+ --gray-800: #1e293b;
22
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
23
+ --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06);
24
+ --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05);
25
+ --shadow-xl: 0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04);
26
+ --radius-sm: 6px;
27
+ --radius-md: 10px;
28
+ --radius-lg: 16px;
29
+ --transition: 200ms ease;
30
+
31
+ /* Trolley colors */
32
+ --trolley-1: #ef4444;
33
+ --trolley-2: #3b82f6;
34
+ --trolley-3: #10b981;
35
+ --trolley-4: #f59e0b;
36
+ --trolley-5: #8b5cf6;
37
+ --trolley-6: #06b6d4;
38
+ --trolley-7: #ec4899;
39
+ --trolley-8: #84cc16;
40
+ }
41
+
42
+ * {
43
+ box-sizing: border-box;
44
+ }
45
+
46
+ body {
47
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
48
+ background: var(--gray-100);
49
+ color: var(--gray-800);
50
+ min-height: 100vh;
51
+ }
52
+
53
+ /* =============================================================================
54
+ Main Container
55
+ ============================================================================= */
56
+
57
+ .main-container {
58
+ padding: 0 1rem 2rem;
59
+ max-width: 1600px;
60
+ margin: 0 auto;
61
+ }
62
+
63
+ /* =============================================================================
64
+ Notification Area
65
+ ============================================================================= */
66
+
67
+ .notification-area {
68
+ position: fixed;
69
+ top: 4rem;
70
+ right: 1rem;
71
+ z-index: 1050;
72
+ max-width: 400px;
73
+ }
74
+
75
+ .notification-area .alert {
76
+ box-shadow: var(--shadow-lg);
77
+ border: none;
78
+ border-radius: var(--radius-md);
79
+ }
80
+
81
+ /* =============================================================================
82
+ Header Bar
83
+ ============================================================================= */
84
+
85
+ .header-bar {
86
+ display: flex;
87
+ align-items: center;
88
+ justify-content: space-between;
89
+ padding: 1rem 0;
90
+ margin-bottom: 1rem;
91
+ gap: 1rem;
92
+ flex-wrap: wrap;
93
+ }
94
+
95
+ .header-left {
96
+ flex: 0 0 auto;
97
+ }
98
+
99
+ .header-left h1 {
100
+ font-size: 1.75rem;
101
+ font-weight: 600;
102
+ color: var(--gray-800);
103
+ margin: 0;
104
+ letter-spacing: -0.02em;
105
+ }
106
+
107
+ .app-title {
108
+ font-size: 1.5rem;
109
+ font-weight: 700;
110
+ color: var(--gray-800);
111
+ margin: 0;
112
+ display: flex;
113
+ align-items: center;
114
+ gap: 0.5rem;
115
+ }
116
+
117
+ .app-title i {
118
+ color: var(--primary);
119
+ }
120
+
121
+ .header-center {
122
+ display: flex;
123
+ align-items: center;
124
+ gap: 1rem;
125
+ flex: 1;
126
+ justify-content: center;
127
+ }
128
+
129
+ .header-right {
130
+ display: flex;
131
+ gap: 0.5rem;
132
+ }
133
+
134
+ /* Buttons */
135
+ .btn-solve {
136
+ background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
137
+ color: white;
138
+ border: none;
139
+ padding: 0.75rem 1.5rem;
140
+ border-radius: var(--radius-md);
141
+ font-weight: 600;
142
+ font-size: 1rem;
143
+ cursor: pointer;
144
+ transition: all var(--transition);
145
+ box-shadow: var(--shadow-md);
146
+ }
147
+
148
+ .btn-solve:hover {
149
+ transform: translateY(-2px);
150
+ box-shadow: var(--shadow-lg);
151
+ background: linear-gradient(135deg, var(--primary-dark) 0%, #047857 100%);
152
+ }
153
+
154
+ .btn-stop {
155
+ background: linear-gradient(135deg, var(--danger) 0%, #dc2626 100%);
156
+ color: white;
157
+ border: none;
158
+ padding: 0.75rem 1.5rem;
159
+ border-radius: var(--radius-md);
160
+ font-weight: 600;
161
+ cursor: pointer;
162
+ transition: all var(--transition);
163
+ box-shadow: var(--shadow-md);
164
+ }
165
+
166
+ .btn-stop:hover {
167
+ transform: translateY(-2px);
168
+ box-shadow: var(--shadow-lg);
169
+ }
170
+
171
+ .btn-settings, .btn-analyze {
172
+ background: white;
173
+ border: 1px solid var(--gray-200);
174
+ color: var(--gray-600);
175
+ width: 44px;
176
+ height: 44px;
177
+ border-radius: var(--radius-md);
178
+ display: flex;
179
+ align-items: center;
180
+ justify-content: center;
181
+ cursor: pointer;
182
+ transition: all var(--transition);
183
+ }
184
+
185
+ .btn-settings:hover, .btn-analyze:hover {
186
+ background: var(--gray-50);
187
+ color: var(--primary);
188
+ border-color: var(--primary);
189
+ }
190
+
191
+ /* Score Display */
192
+ .score-display {
193
+ background: white;
194
+ border-radius: var(--radius-md);
195
+ padding: 0.5rem 1rem;
196
+ box-shadow: var(--shadow-sm);
197
+ border: 1px solid var(--gray-200);
198
+ display: flex;
199
+ flex-direction: column;
200
+ align-items: center;
201
+ min-width: 120px;
202
+ }
203
+
204
+ .score-label {
205
+ font-size: 0.7rem;
206
+ text-transform: uppercase;
207
+ letter-spacing: 0.05em;
208
+ color: var(--gray-500);
209
+ }
210
+
211
+ .score-value {
212
+ font-size: 1.1rem;
213
+ font-weight: 700;
214
+ font-family: 'SF Mono', 'Consolas', monospace;
215
+ color: var(--gray-800);
216
+ }
217
+
218
+ .score-display.improved {
219
+ animation: scoreFlash 0.5s ease-out;
220
+ background: linear-gradient(135deg, var(--primary-light) 0%, var(--primary) 100%);
221
+ border-color: var(--primary);
222
+ }
223
+
224
+ .score-display.improved .score-label,
225
+ .score-display.improved .score-value {
226
+ color: white;
227
+ }
228
+
229
+ @keyframes scoreFlash {
230
+ 0% { transform: scale(1); }
231
+ 50% { transform: scale(1.05); box-shadow: 0 0 20px rgba(16, 185, 129, 0.5); }
232
+ 100% { transform: scale(1); }
233
+ }
234
+
235
+ /* Solving Indicator */
236
+ .solving-indicator {
237
+ display: flex;
238
+ align-items: center;
239
+ gap: 0.5rem;
240
+ color: var(--primary);
241
+ font-weight: 500;
242
+ }
243
+
244
+ .solving-spinner {
245
+ width: 20px;
246
+ height: 20px;
247
+ border: 2px solid var(--primary-light);
248
+ border-top-color: var(--primary);
249
+ border-radius: 50%;
250
+ animation: spin 0.8s linear infinite;
251
+ }
252
+
253
+ @keyframes spin {
254
+ to { transform: rotate(360deg); }
255
+ }
256
+
257
+ /* =============================================================================
258
+ Settings Panel
259
+ ============================================================================= */
260
+
261
+ .settings-panel {
262
+ background: white;
263
+ border-radius: var(--radius-lg);
264
+ padding: 1.5rem;
265
+ margin-bottom: 1rem;
266
+ box-shadow: var(--shadow-md);
267
+ border: 1px solid var(--gray-200);
268
+ }
269
+
270
+ .settings-grid {
271
+ display: grid;
272
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
273
+ gap: 1.5rem;
274
+ align-items: end;
275
+ }
276
+
277
+ .setting-item label {
278
+ display: flex;
279
+ align-items: center;
280
+ gap: 0.5rem;
281
+ font-weight: 500;
282
+ color: var(--gray-700);
283
+ margin-bottom: 0.5rem;
284
+ }
285
+
286
+ .setting-item label i {
287
+ color: var(--primary);
288
+ }
289
+
290
+ .setting-item label .badge {
291
+ background: var(--primary);
292
+ color: white;
293
+ padding: 0.2rem 0.5rem;
294
+ border-radius: 4px;
295
+ font-size: 0.8rem;
296
+ margin-left: auto;
297
+ }
298
+
299
+ .setting-item input[type="range"] {
300
+ width: 100%;
301
+ accent-color: var(--primary);
302
+ height: 6px;
303
+ cursor: pointer;
304
+ }
305
+
306
+ .btn-generate {
307
+ background: linear-gradient(135deg, var(--info) 0%, #2563eb 100%);
308
+ color: white;
309
+ border: none;
310
+ padding: 0.75rem 1.25rem;
311
+ border-radius: var(--radius-md);
312
+ font-weight: 600;
313
+ cursor: pointer;
314
+ transition: all var(--transition);
315
+ width: 100%;
316
+ }
317
+
318
+ .btn-generate:hover {
319
+ transform: translateY(-2px);
320
+ box-shadow: var(--shadow-md);
321
+ }
322
+
323
+ /* =============================================================================
324
+ Content Area
325
+ ============================================================================= */
326
+
327
+ .content-area {
328
+ display: grid;
329
+ grid-template-columns: 140px 1fr;
330
+ gap: 1rem;
331
+ margin-bottom: 1.5rem;
332
+ }
333
+
334
+ /* Stats Sidebar */
335
+ .stats-sidebar {
336
+ display: flex;
337
+ flex-direction: column;
338
+ gap: 0.75rem;
339
+ }
340
+
341
+ .stat-card {
342
+ background: white;
343
+ border-radius: var(--radius-md);
344
+ padding: 1rem;
345
+ box-shadow: var(--shadow-sm);
346
+ border: 1px solid var(--gray-200);
347
+ display: flex;
348
+ align-items: center;
349
+ gap: 0.75rem;
350
+ transition: all var(--transition);
351
+ }
352
+
353
+ .stat-card:hover {
354
+ transform: translateX(4px);
355
+ box-shadow: var(--shadow-md);
356
+ }
357
+
358
+ .stat-card .stat-icon {
359
+ width: 40px;
360
+ height: 40px;
361
+ background: linear-gradient(135deg, var(--primary-light) 0%, var(--primary) 100%);
362
+ border-radius: var(--radius-sm);
363
+ display: flex;
364
+ align-items: center;
365
+ justify-content: center;
366
+ color: white;
367
+ font-size: 1rem;
368
+ }
369
+
370
+ .stat-card .stat-info {
371
+ display: flex;
372
+ flex-direction: column;
373
+ }
374
+
375
+ .stat-card .stat-value {
376
+ font-size: 1.25rem;
377
+ font-weight: 700;
378
+ color: var(--gray-800);
379
+ line-height: 1.2;
380
+ }
381
+
382
+ .stat-card .stat-label {
383
+ font-size: 0.75rem;
384
+ color: var(--gray-500);
385
+ text-transform: uppercase;
386
+ letter-spacing: 0.03em;
387
+ }
388
+
389
+ /* Canvas Area */
390
+ .canvas-area {
391
+ background: white;
392
+ border-radius: var(--radius-lg);
393
+ box-shadow: var(--shadow-md);
394
+ border: 1px solid var(--gray-200);
395
+ overflow: hidden;
396
+ position: relative;
397
+ }
398
+
399
+ #warehouseContainer {
400
+ position: relative;
401
+ width: 100%;
402
+ min-height: 500px;
403
+ display: flex;
404
+ align-items: center;
405
+ justify-content: center;
406
+ background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
407
+ }
408
+
409
+ #warehouseCanvas {
410
+ display: block;
411
+ max-width: 100%;
412
+ height: auto;
413
+ }
414
+
415
+ /* Legend Overlay */
416
+ #legendOverlay {
417
+ position: absolute;
418
+ top: 1rem;
419
+ right: 1rem;
420
+ background: rgba(255, 255, 255, 0.95);
421
+ backdrop-filter: blur(8px);
422
+ border-radius: var(--radius-md);
423
+ padding: 0.75rem;
424
+ box-shadow: var(--shadow-md);
425
+ border: 1px solid var(--gray-200);
426
+ min-width: 140px;
427
+ z-index: 10;
428
+ }
429
+
430
+ .legend-header {
431
+ font-size: 0.75rem;
432
+ font-weight: 600;
433
+ text-transform: uppercase;
434
+ letter-spacing: 0.05em;
435
+ color: var(--gray-500);
436
+ margin-bottom: 0.5rem;
437
+ padding-bottom: 0.5rem;
438
+ border-bottom: 1px solid var(--gray-200);
439
+ display: flex;
440
+ align-items: center;
441
+ gap: 0.5rem;
442
+ }
443
+
444
+ .legend-header i {
445
+ color: var(--primary);
446
+ }
447
+
448
+ .legend-item {
449
+ display: flex;
450
+ align-items: center;
451
+ gap: 0.5rem;
452
+ padding: 0.35rem 0;
453
+ font-size: 0.85rem;
454
+ color: var(--gray-600);
455
+ cursor: pointer;
456
+ border-radius: var(--radius-sm);
457
+ transition: all var(--transition);
458
+ }
459
+
460
+ .legend-item:hover {
461
+ background: var(--gray-100);
462
+ padding-left: 0.5rem;
463
+ }
464
+
465
+ .legend-color {
466
+ width: 16px;
467
+ height: 16px;
468
+ border-radius: 4px;
469
+ flex-shrink: 0;
470
+ }
471
+
472
+ .legend-text {
473
+ flex: 1;
474
+ font-weight: 500;
475
+ }
476
+
477
+ .legend-distance {
478
+ font-size: 0.75rem;
479
+ color: var(--gray-400);
480
+ font-family: 'SF Mono', monospace;
481
+ }
482
+
483
+ /* =============================================================================
484
+ Trolley Section
485
+ ============================================================================= */
486
+
487
+ .trolley-section {
488
+ margin-top: 1rem;
489
+ }
490
+
491
+ .section-header {
492
+ margin-bottom: 1rem;
493
+ }
494
+
495
+ .section-header h2 {
496
+ font-size: 1.25rem;
497
+ font-weight: 600;
498
+ color: var(--gray-800);
499
+ display: flex;
500
+ align-items: center;
501
+ gap: 0.5rem;
502
+ margin: 0;
503
+ }
504
+
505
+ .section-header h2 i {
506
+ color: var(--primary);
507
+ }
508
+
509
+ .trolley-cards-grid {
510
+ display: grid;
511
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
512
+ gap: 1rem;
513
+ }
514
+
515
+ /* Trolley Card */
516
+ .trolley-card {
517
+ background: white;
518
+ border-radius: var(--radius-lg);
519
+ box-shadow: var(--shadow-sm);
520
+ border: 1px solid var(--gray-200);
521
+ overflow: hidden;
522
+ transition: all var(--transition);
523
+ cursor: pointer;
524
+ }
525
+
526
+ .trolley-card:hover {
527
+ box-shadow: var(--shadow-lg);
528
+ transform: translateY(-2px);
529
+ }
530
+
531
+ .trolley-card-header {
532
+ padding: 1rem;
533
+ display: flex;
534
+ align-items: center;
535
+ gap: 1rem;
536
+ border-bottom: 1px solid var(--gray-100);
537
+ }
538
+
539
+ .trolley-color-badge {
540
+ width: 12px;
541
+ height: 40px;
542
+ border-radius: 4px;
543
+ flex-shrink: 0;
544
+ }
545
+
546
+ .trolley-card-info {
547
+ flex: 1;
548
+ }
549
+
550
+ .trolley-card-title {
551
+ font-weight: 600;
552
+ font-size: 1rem;
553
+ color: var(--gray-800);
554
+ }
555
+
556
+ .trolley-card-stats {
557
+ font-size: 0.85rem;
558
+ color: var(--gray-500);
559
+ margin-top: 0.25rem;
560
+ }
561
+
562
+ .trolley-capacity-bar {
563
+ width: 80px;
564
+ height: 8px;
565
+ background: var(--gray-200);
566
+ border-radius: 4px;
567
+ overflow: hidden;
568
+ }
569
+
570
+ .trolley-capacity-fill {
571
+ height: 100%;
572
+ border-radius: 4px;
573
+ transition: width var(--transition);
574
+ }
575
+
576
+ .trolley-capacity-fill.low { background: var(--primary); }
577
+ .trolley-capacity-fill.medium { background: var(--warning); }
578
+ .trolley-capacity-fill.high { background: var(--danger); }
579
+
580
+ .trolley-card-body {
581
+ padding: 1rem;
582
+ background: var(--gray-50);
583
+ max-height: 200px;
584
+ overflow-y: auto;
585
+ }
586
+
587
+ .trolley-items-list {
588
+ display: flex;
589
+ flex-wrap: wrap;
590
+ gap: 0.5rem;
591
+ }
592
+
593
+ .trolley-item {
594
+ background: white;
595
+ border: 1px solid var(--gray-200);
596
+ border-radius: var(--radius-sm);
597
+ padding: 0.35rem 0.6rem;
598
+ font-size: 0.8rem;
599
+ display: flex;
600
+ align-items: center;
601
+ gap: 0.35rem;
602
+ }
603
+
604
+ .trolley-item-number {
605
+ background: var(--primary);
606
+ color: white;
607
+ width: 18px;
608
+ height: 18px;
609
+ border-radius: 50%;
610
+ display: flex;
611
+ align-items: center;
612
+ justify-content: center;
613
+ font-size: 0.7rem;
614
+ font-weight: 600;
615
+ }
616
+
617
+ .trolley-empty {
618
+ color: var(--gray-400);
619
+ font-style: italic;
620
+ padding: 1rem;
621
+ text-align: center;
622
+ }
623
+
624
+ /* =============================================================================
625
+ 3D Bucket View Modal
626
+ ============================================================================= */
627
+
628
+ .bucket-modal .modal-body {
629
+ padding: 0;
630
+ background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
631
+ min-height: 400px;
632
+ }
633
+
634
+ #bucketViewCanvas {
635
+ width: 100%;
636
+ height: 400px;
637
+ display: block;
638
+ }
639
+
640
+ .bucket-modal-stats {
641
+ background: rgba(255, 255, 255, 0.1);
642
+ backdrop-filter: blur(8px);
643
+ padding: 1rem;
644
+ display: flex;
645
+ justify-content: space-around;
646
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
647
+ }
648
+
649
+ .bucket-stat {
650
+ text-align: center;
651
+ color: white;
652
+ }
653
+
654
+ .bucket-stat-value {
655
+ font-size: 1.5rem;
656
+ font-weight: 700;
657
+ }
658
+
659
+ .bucket-stat-label {
660
+ font-size: 0.75rem;
661
+ opacity: 0.7;
662
+ text-transform: uppercase;
663
+ }
664
+
665
+ /* =============================================================================
666
+ Modal Styling
667
+ ============================================================================= */
668
+
669
+ .modal-content {
670
+ border: none;
671
+ border-radius: var(--radius-lg);
672
+ overflow: hidden;
673
+ box-shadow: var(--shadow-xl);
674
+ }
675
+
676
+ .modal-header {
677
+ background: var(--gray-50);
678
+ border-bottom: 1px solid var(--gray-200);
679
+ padding: 1rem 1.5rem;
680
+ }
681
+
682
+ .modal-title {
683
+ font-weight: 600;
684
+ display: flex;
685
+ align-items: center;
686
+ gap: 0.5rem;
687
+ }
688
+
689
+ .modal-title i {
690
+ color: var(--primary);
691
+ }
692
+
693
+ .modal-body {
694
+ padding: 1.5rem;
695
+ }
696
+
697
+ .modal-footer {
698
+ border-top: 1px solid var(--gray-200);
699
+ padding: 1rem 1.5rem;
700
+ }
701
+
702
+ /* Score Analysis */
703
+ .constraint-group {
704
+ margin-bottom: 1rem;
705
+ border: 1px solid var(--gray-200);
706
+ border-radius: var(--radius-md);
707
+ overflow: hidden;
708
+ }
709
+
710
+ .constraint-header {
711
+ background: var(--gray-50);
712
+ padding: 0.75rem 1rem;
713
+ display: flex;
714
+ justify-content: space-between;
715
+ align-items: center;
716
+ cursor: pointer;
717
+ }
718
+
719
+ .constraint-name {
720
+ font-weight: 600;
721
+ color: var(--gray-800);
722
+ }
723
+
724
+ .constraint-score {
725
+ font-family: 'SF Mono', monospace;
726
+ font-size: 0.9rem;
727
+ }
728
+
729
+ .constraint-score.hard { color: var(--danger); }
730
+ .constraint-score.soft { color: var(--warning); }
731
+
732
+ /* =============================================================================
733
+ Responsive
734
+ ============================================================================= */
735
+
736
+ @media (max-width: 768px) {
737
+ .header-bar {
738
+ flex-direction: column;
739
+ align-items: stretch;
740
+ }
741
+
742
+ .header-center {
743
+ order: 3;
744
+ justify-content: center;
745
+ }
746
+
747
+ .header-right {
748
+ position: absolute;
749
+ right: 1rem;
750
+ top: 1rem;
751
+ }
752
+
753
+ .content-area {
754
+ grid-template-columns: 1fr;
755
+ }
756
+
757
+ .stats-sidebar {
758
+ flex-direction: row;
759
+ flex-wrap: wrap;
760
+ }
761
+
762
+ .stat-card {
763
+ flex: 1;
764
+ min-width: 120px;
765
+ }
766
+
767
+ .trolley-cards-grid {
768
+ grid-template-columns: 1fr;
769
+ }
770
+
771
+ #legendOverlay {
772
+ position: relative;
773
+ top: auto;
774
+ right: auto;
775
+ margin: 1rem;
776
+ }
777
+ }
778
+
779
+ /* =============================================================================
780
+ Animations
781
+ ============================================================================= */
782
+
783
+ @keyframes fadeIn {
784
+ from { opacity: 0; transform: translateY(10px); }
785
+ to { opacity: 1; transform: translateY(0); }
786
+ }
787
+
788
+ @keyframes pulse {
789
+ 0%, 100% { transform: scale(1); }
790
+ 50% { transform: scale(1.02); }
791
+ }
792
+
793
+ .fade-in {
794
+ animation: fadeIn 0.3s ease-out;
795
+ }
796
+
797
+ .pulse {
798
+ animation: pulse 2s ease-in-out infinite;
799
+ }
800
+
801
+ /* Value change animation */
802
+ .value-changed {
803
+ animation: valueChange 0.5s ease-out;
804
+ }
805
+
806
+ @keyframes valueChange {
807
+ 0% { transform: scale(1); color: var(--primary); }
808
+ 50% { transform: scale(1.2); }
809
+ 100% { transform: scale(1); color: inherit; }
810
+ }
static/warehouse-api.js ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const LEFT = 'LEFT';
2
+ const RIGHT = 'RIGHT';
3
+
4
+ const WAREHOUSE_COLUMNS = ['A', 'B', 'C', 'D', 'E'];
5
+ const WAREHOUSE_ROWS = ['1', '2', '3'];
6
+
7
+ const WAREHOUSE_PADDING_TOP = 100;
8
+ const WAREHOUSE_PADDING_LEFT = 100;
9
+
10
+ const SHELVING_PADDING = 80;
11
+ const SHELVING_WIDTH = 150;
12
+ const SHELVING_HEIGHT = 300;
13
+ const SHELVING_ROWS = 10;
14
+
15
+ const SHELVING_LINE_WIDTH = 5;
16
+ const SHELVING_STROKE_STYLE = 'green';
17
+ const SHELVING_LINE_JOIN = 'bevel';
18
+
19
+ const TROLLEY_PATH_LINE_WIDTH = 5;
20
+ const TROLLEY_PATH_LINE_JOIN = 'round';
21
+
22
+ const SHELVINGS_MAP = new Map();
23
+
24
+ function shelvingId(i, j) {
25
+ return '(' + i + ',' + j + ')';
26
+ }
27
+
28
+ class Point {
29
+ x = 0;
30
+ y = 0;
31
+
32
+ constructor(x, y) {
33
+ this.x = x;
34
+ this.y = y;
35
+ }
36
+ }
37
+
38
+ class ShelvingShape {
39
+ id;
40
+ x = 0;
41
+ y = 0;
42
+ width = 0;
43
+ height = 0;
44
+
45
+ constructor(id, x, y, width, height) {
46
+ this.id = id;
47
+ this.x = x;
48
+ this.y = y;
49
+ this.width = width;
50
+ this.height = height;
51
+ }
52
+ }
53
+
54
+ class WarehouseLocation {
55
+ shelvingId;
56
+ side;
57
+ row;
58
+
59
+ constructor(shelvingId, side, row) {
60
+ this.shelvingId = shelvingId;
61
+ this.side = side;
62
+ this.row = row;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Returns the WarehouseCanvas.
68
+ */
69
+ function getWarehouseCanvasContext() {
70
+ return document.getElementById('warehouseCanvas').getContext('2d');
71
+ }
72
+
73
+ /**
74
+ * Clears the WarehouseCanvas.
75
+ */
76
+ function clearWarehouseCanvas() {
77
+ const canvas = document.getElementById('warehouseCanvas');
78
+ const ctx = canvas.getContext('2d');
79
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
80
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
81
+ const parentWidth = canvas.parentElement.clientWidth;
82
+ const parentHeight = canvas.parentElement.clientHeight;
83
+ const contentWidth = 2 * WAREHOUSE_PADDING_LEFT + (SHELVING_WIDTH + SHELVING_PADDING) * WAREHOUSE_COLUMNS.length;
84
+ const contentHeight = 2 * WAREHOUSE_PADDING_TOP + (SHELVING_HEIGHT + SHELVING_PADDING) * WAREHOUSE_ROWS.length;
85
+ const minToFillParent = Math.min(parentWidth / contentWidth, parentHeight / contentHeight);
86
+ const xOffset = (parentWidth - (contentWidth * minToFillParent)) / 2;
87
+ const yOffset = (parentHeight - (contentHeight * minToFillParent)) / 2;
88
+ canvas.width = parentWidth;
89
+ canvas.height = parentHeight;
90
+ ctx.translate(xOffset, yOffset);
91
+ ctx.scale(minToFillParent, minToFillParent);
92
+ }
93
+
94
+ /**
95
+ * Draws the WarehouseStructure on the WarehouseCanvas.
96
+ */
97
+ function drawWarehouse() {
98
+ const ctx = getWarehouseCanvasContext();
99
+ let x = WAREHOUSE_PADDING_LEFT;
100
+ for (let column = 0; column < WAREHOUSE_COLUMNS.length; column++) {
101
+ let y = WAREHOUSE_PADDING_TOP;
102
+ for (let row = 0; row < WAREHOUSE_ROWS.length; row++) {
103
+ const shelving = new ShelvingShape(shelvingId(WAREHOUSE_COLUMNS[column], WAREHOUSE_ROWS[row]), x, y, SHELVING_WIDTH, SHELVING_HEIGHT);
104
+ drawShelving(ctx, shelving);
105
+ SHELVINGS_MAP.set(shelving.id, shelving);
106
+ y = y + SHELVING_HEIGHT + SHELVING_PADDING;
107
+ }
108
+ x = x + SHELVING_WIDTH + SHELVING_PADDING;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Draws a ShelvingShape on the Warehouse canvas.
114
+ */
115
+ function drawShelving(ctx, shelving) {
116
+ ctx.strokeStyle = SHELVING_STROKE_STYLE;
117
+ ctx.lineJoin = SHELVING_LINE_JOIN;
118
+ ctx.lineWidth = SHELVING_LINE_WIDTH;
119
+ ctx.strokeRect(shelving.x, shelving.y, shelving.width, shelving.height);
120
+
121
+ ctx.font = '30px serif';
122
+ ctx.textAlign = 'center';
123
+ ctx.fillStyle = SHELVING_STROKE_STYLE
124
+
125
+ const shelvingTextParts = shelving.id.split(',');
126
+ ctx.fillText(shelvingTextParts[0].substring(1) + shelvingTextParts[1].substring(0, shelvingTextParts[1].length - 1),
127
+ shelving.x + shelving.width / 2, shelving.y + shelving.height / 2);
128
+ }
129
+
130
+ /**
131
+ * Draws the path travelled by a Trolley.
132
+ */
133
+ function drawTrolleyPath(strokeStyle, warehouseLocations, trolleyIndex, trolleyCount) {
134
+ const ctx = getWarehouseCanvasContext();
135
+ ctx.lineJoin = TROLLEY_PATH_LINE_JOIN;
136
+ ctx.lineWidth = TROLLEY_PATH_LINE_WIDTH;
137
+ ctx.strokeStyle = strokeStyle;
138
+ drawWarehousePath(warehouseLocations, trolleyIndex, trolleyCount);
139
+ }
140
+
141
+ function drawTrolleyText(strokeStyle, warehouseLocations, trolleyIndex, trolleyCount) {
142
+ const ctx = getWarehouseCanvasContext();
143
+ ctx.lineJoin = TROLLEY_PATH_LINE_JOIN;
144
+ ctx.lineWidth = TROLLEY_PATH_LINE_WIDTH;
145
+ ctx.strokeStyle = strokeStyle;
146
+ drawTextForTrolley(strokeStyle, warehouseLocations, trolleyIndex, trolleyCount);
147
+ }
148
+
149
+ /**
150
+ * Draws a path composed of WarehouseLocations.
151
+ */
152
+ function drawWarehousePath(warehouseLocations, trolleyIndex, trolleyCount) {
153
+ const ctx = getWarehouseCanvasContext();
154
+ const startLocation = warehouseLocations[0];
155
+ const startShelving = SHELVINGS_MAP.get(startLocation.shelvingId);
156
+ const startPoint = location2Point(startShelving, startLocation.side, startLocation.row, trolleyIndex, trolleyCount);
157
+ let lastPoint = startPoint;
158
+ let lastShelving = startShelving;
159
+ let lastSide = startLocation.side;
160
+ let lastRow = startLocation.row;
161
+
162
+ ctx.beginPath();
163
+ ctx.moveTo(startPoint.x, startPoint.y);
164
+ ctx.arc(startPoint.x, startPoint.y, 5, 0, 2 * Math.PI);
165
+
166
+ for (let i = 1; i < warehouseLocations.length; i++) {
167
+ const location = warehouseLocations[i];
168
+ const shelving = SHELVINGS_MAP.get(location.shelvingId);
169
+ const side = location.side;
170
+ const row = location.row;
171
+ const point = location2Point(shelving, location.side, location.row, trolleyIndex, trolleyCount);
172
+ drawWarehousePathBetweenShelves(ctx, trolleyIndex, trolleyCount, lastShelving, lastSide, lastRow, lastPoint,
173
+ shelving, side, row, point);
174
+ ctx.arc(point.x, point.y, 5, 0, 2 * Math.PI);
175
+ lastPoint = point;
176
+ lastShelving = shelving;
177
+ lastSide = side;
178
+ lastRow = row;
179
+ }
180
+ ctx.stroke();
181
+ ctx.closePath();
182
+ }
183
+
184
+ function drawTextForTrolley(strokeStyle, warehouseLocations, trolleyIndex, trolleyCount) {
185
+ const ctx = getWarehouseCanvasContext();
186
+
187
+ ctx.fillStyle = strokeStyle;
188
+ ctx.strokeStyle = "#000000";
189
+ let overlappingOrderTexts = [];
190
+ const SHELVING_ROW_HEIGHT = SHELVING_HEIGHT / SHELVING_ROWS;
191
+ const TEXT_SEPERATOR_HEIGHT = SHELVING_ROW_HEIGHT * 4;
192
+ const SEPERATION_PER_TROLLEY = TEXT_SEPERATOR_HEIGHT / trolleyCount;
193
+ for (let i = 0; i < warehouseLocations.length; i++) {
194
+ const location = warehouseLocations[i];
195
+ const shelving = SHELVINGS_MAP.get(location.shelvingId);
196
+ const point = location2Point(shelving, location.side, location.row, trolleyIndex, trolleyCount);
197
+ const pointFlooredRow = Math.floor(point.y / TEXT_SEPERATOR_HEIGHT) * TEXT_SEPERATOR_HEIGHT;
198
+
199
+ point.y = pointFlooredRow + SEPERATION_PER_TROLLEY * trolleyIndex;
200
+ addToOverlap(overlappingOrderTexts, i.toString(10), point);
201
+ }
202
+
203
+ for (let i = 0; i < overlappingOrderTexts.length; i++) {
204
+ const overlappingOrders = overlappingOrderTexts[i];
205
+ const text = '(' + overlappingOrders.orders.join(', ') + ')';
206
+
207
+ ctx.strokeText(text, overlappingOrders.x, overlappingOrders.y);
208
+ ctx.fillText(text, overlappingOrders.x, overlappingOrders.y);
209
+ }
210
+ }
211
+
212
+ function addToOverlap(overlappingOrderTexts, orderText, orderPoint) {
213
+ const SHELVING_ROW_HEIGHT = SHELVING_HEIGHT / SHELVING_ROWS;
214
+ for (let i = 0; i < overlappingOrderTexts.length; i++) {
215
+ const overlappingOrderText = overlappingOrderTexts[i];
216
+ const distance = Math.abs(orderPoint.x - overlappingOrderText.x) + Math.abs(orderPoint.y - overlappingOrderText.y);
217
+ if (distance < 2 * SHELVING_ROW_HEIGHT) {
218
+ overlappingOrderText.orders.push(orderText);
219
+ return;
220
+ }
221
+ }
222
+ const newOverlappingOrderText = {
223
+ orders: [orderText],
224
+ x: orderPoint.x,
225
+ y: orderPoint.y,
226
+ };
227
+ overlappingOrderTexts.push(newOverlappingOrderText);
228
+ }
229
+
230
+ /**
231
+ * Draw a path around shelves connecting two WarehouseLocations
232
+ */
233
+ function drawWarehousePathBetweenShelves(ctx, trolleyIndex, trolleyCount,
234
+ startShelving, startSide, startRow, startPoint,
235
+ endShelving, endSide, endRow, endPoint) {
236
+ ctx.moveTo(startPoint.x, startPoint.y);
237
+ if (startShelving === endShelving) {
238
+ if (startSide === endSide) {
239
+ // Two points on the same shelf and same side
240
+ ctx.lineTo(endPoint.x, endPoint.y);
241
+ } else {
242
+ // Two points on the same shelf but different sides
243
+ const isAbove = startRow + endRow < 2*SHELVING_ROWS - startRow - endRow;
244
+ const aisleChangeStartPoint = location2AisleLane(startShelving, startSide, isAbove, trolleyIndex, trolleyCount);
245
+ const aisleChangeEndPoint = location2AisleLane(endShelving, endSide, isAbove, trolleyIndex, trolleyCount);
246
+ ctx.lineTo(aisleChangeStartPoint.x, aisleChangeStartPoint.y);
247
+ ctx.lineTo(aisleChangeEndPoint.x, aisleChangeEndPoint.y);
248
+ ctx.lineTo(endPoint.x, endPoint.y);
249
+ }
250
+ } else if (startShelving.x === endShelving.x) {
251
+ if (startSide === endSide) {
252
+ // Same Aisle, different rows
253
+ ctx.lineTo(endPoint.x, endPoint.y);
254
+ } else {
255
+ // Different Aisle
256
+ if (startShelving.y < endShelving.y) {
257
+ // Going up
258
+ const aisleChangeStartPoint = location2AisleLane(endShelving, startSide, false, trolleyIndex, trolleyCount);
259
+ const aisleChangeEndPoint = location2AisleLane(endShelving, endSide, false, trolleyIndex, trolleyCount);
260
+ ctx.lineTo(aisleChangeStartPoint.x, aisleChangeStartPoint.y);
261
+ ctx.lineTo(aisleChangeEndPoint.x, aisleChangeEndPoint.y);
262
+ ctx.lineTo(endPoint.x, endPoint.y);
263
+ } else {
264
+ // Going down
265
+ const aisleChangeStartPoint = location2AisleLane(endShelving, startSide, true, trolleyIndex, trolleyCount);
266
+ const aisleChangeEndPoint = location2AisleLane(endShelving, endSide, true, trolleyIndex, trolleyCount);
267
+ ctx.lineTo(aisleChangeStartPoint.x, aisleChangeStartPoint.y);
268
+ ctx.lineTo(aisleChangeEndPoint.x, aisleChangeEndPoint.y);
269
+ ctx.lineTo(endPoint.x, endPoint.y);
270
+ }
271
+ }
272
+ } else if (startShelving.y === endShelving.y) {
273
+ const startColumn = shelvingToColumn(startShelving);
274
+ const endColumn = shelvingToColumn(endShelving);
275
+ if (startSide === LEFT) {
276
+ if (endSide === RIGHT && endColumn === startColumn - 1) {
277
+ // Same Aisle, different shelving
278
+ ctx.lineTo(endPoint.x, startPoint.y);
279
+ ctx.lineTo(endPoint.x, endPoint.y);
280
+ } else {
281
+ drawWarehousePathBetweenColumns(ctx, trolleyIndex, trolleyCount,
282
+ startShelving, startSide, startRow, startPoint,
283
+ endShelving, endSide, endRow, endPoint);
284
+ }
285
+ } else {
286
+ if (endSide === LEFT && endColumn === startColumn + 1) {
287
+ // Same Aisle, different shelving
288
+ ctx.lineTo(endPoint.x, startPoint.y);
289
+ ctx.lineTo(endPoint.x, endPoint.y);
290
+ } else {
291
+ drawWarehousePathBetweenColumns(ctx, trolleyIndex, trolleyCount,
292
+ startShelving, startSide, startRow, startPoint,
293
+ endShelving, endSide, endRow, endPoint);
294
+ }
295
+ }
296
+ } else {
297
+ drawWarehousePathBetweenColumns(ctx, trolleyIndex, trolleyCount,
298
+ startShelving, startSide, startRow, startPoint,
299
+ endShelving, endSide, endRow, endPoint);
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Draws a path between shelvings in two different columns
305
+ */
306
+ function drawWarehousePathBetweenColumns(ctx, trolleyIndex, trolleyCount,
307
+ startShelving, startSide, startRow, startPoint,
308
+ endShelving, endSide, endRow, endPoint) {
309
+ if (startShelving.y === endShelving.y) {
310
+ const isAbove = startRow + endRow < 2*SHELVING_ROWS - startRow - endRow;
311
+ const aisleChangeStartPoint = location2AisleLane(startShelving, startSide, isAbove, trolleyIndex, trolleyCount);
312
+ const aisleChangeEndPoint = location2AisleLane(endShelving, endSide, isAbove, trolleyIndex, trolleyCount);
313
+ ctx.lineTo(aisleChangeStartPoint.x, aisleChangeStartPoint.y);
314
+ ctx.lineTo(aisleChangeEndPoint.x, aisleChangeEndPoint.y);
315
+ ctx.lineTo(endPoint.x, endPoint.y);
316
+ } else {
317
+ const isAbove = endShelving.y < startShelving.y;
318
+ const aisleChangeStartPoint = location2AisleLane(startShelving, startSide, isAbove, trolleyIndex, trolleyCount);
319
+ const aisleChangeEndPoint = location2AisleLane(endShelving, endSide, isAbove, trolleyIndex, trolleyCount);
320
+ ctx.lineTo(aisleChangeStartPoint.x, aisleChangeStartPoint.y);
321
+ ctx.lineTo(aisleChangeEndPoint.x, aisleChangeStartPoint.y);
322
+ ctx.lineTo(aisleChangeEndPoint.x, aisleChangeEndPoint.y);
323
+ ctx.lineTo(endPoint.x, endPoint.y);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Transforms a WarehouseLocation into an absolute Point in the canvas.
329
+ */
330
+ function location2Point(shelving, side, row, trolleyIndex, trolleyCount) {
331
+ let x;
332
+ let y;
333
+ if (side === LEFT) {
334
+ x = shelving.x - SHELVING_LINE_WIDTH - ((0.5 * SHELVING_PADDING) / trolleyCount) * trolleyIndex;
335
+ } else {
336
+ x = shelving.x + shelving.width + SHELVING_LINE_WIDTH + ((0.5 * SHELVING_PADDING) / trolleyCount) * trolleyIndex;
337
+ }
338
+ const rowWidth = shelving.height / SHELVING_ROWS;
339
+ y = shelving.y + row * rowWidth;
340
+
341
+ return new Point(x, y);
342
+ }
343
+
344
+ /**
345
+ * Get location for the aisle lane above or below a shelving side
346
+ */
347
+ function location2AisleLane(shelving, side, isAbove, trolleyIndex, trolleyCount) {
348
+ let x;
349
+ let y;
350
+ if (side === LEFT) {
351
+ x = shelving.x - SHELVING_LINE_WIDTH - ((0.5 * SHELVING_PADDING) / trolleyCount) * trolleyIndex;
352
+ } else {
353
+ x = shelving.x + SHELVING_LINE_WIDTH + shelving.width + ((0.5 * SHELVING_PADDING) / trolleyCount) * trolleyIndex;
354
+ }
355
+
356
+ if (isAbove) {
357
+ y = shelving.y - 0.25 * SHELVING_PADDING - (((0.25 * SHELVING_PADDING) / trolleyCount) * trolleyIndex);
358
+ } else {
359
+ y = shelving.y + shelving.height + 0.25 * SHELVING_PADDING + (((0.25 * SHELVING_PADDING) / trolleyCount) * trolleyIndex);
360
+ }
361
+
362
+ return new Point(x, y);
363
+ }
364
+
365
+ /**
366
+ * Get the column number of a shelving
367
+ */
368
+ function shelvingToColumn(shelving) {
369
+ const COLUMN_WIDTH = SHELVING_WIDTH + SHELVING_PADDING;
370
+ return (shelving.x - WAREHOUSE_PADDING_LEFT) / COLUMN_WIDTH;
371
+ }
static/webjars/solverforge/css/solverforge-webui.css ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Keep in sync with .navbar height on a large screen. */
3
+ --ts-navbar-height: 109px;
4
+
5
+ --ts-green-1-rgb: #10b981;
6
+ --ts-green-2-rgb: #059669;
7
+ --ts-violet-1-rgb: #3E00FF;
8
+ --ts-violet-2-rgb: #3423A6;
9
+ --ts-violet-3-rgb: #2E1760;
10
+ --ts-violet-4-rgb: #200F4F;
11
+ --ts-violet-5-rgb: #000000; /* TODO FIXME */
12
+ --ts-violet-dark-1-rgb: #b6adfd;
13
+ --ts-violet-dark-2-rgb: #c1bbfd;
14
+ --ts-gray-rgb: #666666;
15
+ --ts-white-rgb: #FFFFFF;
16
+ --ts-light-rgb: #F2F2F2;
17
+ --ts-gray-border: #c5c5c5;
18
+
19
+ --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
20
+ --bs-body-bg: var(--ts-light-rgb); /* link to html bg */
21
+ --bs-link-color: var(--ts-violet-1-rgb);
22
+ --bs-link-hover-color: var(--ts-violet-2-rgb);
23
+
24
+ --bs-navbar-color: var(--ts-white-rgb);
25
+ --bs-navbar-hover-color: var(--ts-white-rgb);
26
+ --bs-nav-link-font-size: 18px;
27
+ --bs-nav-link-font-weight: 400;
28
+ --bs-nav-link-color: var(--ts-white-rgb);
29
+ --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
30
+ }
31
+ .btn {
32
+ --bs-btn-border-radius: 1.5rem;
33
+ }
34
+ .btn-primary {
35
+ --bs-btn-bg: var(--ts-violet-1-rgb);
36
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
37
+ --bs-btn-hover-bg: var(--ts-violet-2-rgb);
38
+ --bs-btn-hover-border-color: var(--ts-violet-2-rgb);
39
+ --bs-btn-active-bg: var(--ts-violet-2-rgb);
40
+ --bs-btn-active-border-bg: var(--ts-violet-2-rgb);
41
+ --bs-btn-disabled-bg: var(--ts-violet-1-rgb);
42
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
43
+ }
44
+ .btn-outline-primary {
45
+ --bs-btn-color: var(--ts-violet-1-rgb);
46
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
47
+ --bs-btn-hover-bg: var(--ts-violet-1-rgb);
48
+ --bs-btn-hover-border-color: var(--ts-violet-1-rgb);
49
+ --bs-btn-active-bg: var(--ts-violet-1-rgb);
50
+ --bs-btn-active-border-color: var(--ts-violet-1-rgb);
51
+ --bs-btn-disabled-color: var(--ts-violet-1-rgb);
52
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
53
+ }
54
+ .navbar-dark {
55
+ --bs-link-color: var(--ts-violet-dark-1-rgb);
56
+ --bs-link-hover-color: var(--ts-violet-dark-2-rgb);
57
+ --bs-navbar-color: var(--ts-white-rgb);
58
+ --bs-navbar-hover-color: var(--ts-white-rgb);
59
+ }
60
+ .nav-pills {
61
+ --bs-nav-pills-link-active-bg: var(--ts-green-1-rgb);
62
+ }
63
+ .nav-pills .nav-link:hover {
64
+ color: var(--ts-green-1-rgb);
65
+ }
66
+ .nav-pills .nav-link.active:hover {
67
+ color: var(--ts-white-rgb);
68
+ }
static/webjars/solverforge/img/solverforge-favicon.svg ADDED
static/webjars/solverforge/img/solverforge-horizontal-white.svg ADDED
static/webjars/solverforge/img/solverforge-horizontal.svg ADDED
static/webjars/solverforge/img/solverforge-logo-stacked.svg ADDED
static/webjars/solverforge/js/solverforge-webui.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function replaceSolverForgeAutoHeaderFooter() {
2
+ const solverforgeHeader = $("header#solverforge-auto-header");
3
+ if (solverforgeHeader != null) {
4
+ solverforgeHeader.addClass("bg-black")
5
+ solverforgeHeader.append(
6
+ $(`<div class="container-fluid">
7
+ <nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
8
+ <a class="navbar-brand" href="https://www.solverforge.org">
9
+ <img src="/solverforge/img/solverforge-horizontal-white.svg" alt="SolverForge logo" width="200">
10
+ </a>
11
+ </nav>
12
+ </div>`));
13
+ }
14
+ const solverforgeFooter = $("footer#solverforge-auto-footer");
15
+ if (solverforgeFooter != null) {
16
+ solverforgeFooter.append(
17
+ $(`<footer class="bg-black text-white-50">
18
+ <div class="container">
19
+ <div class="hstack gap-3 p-4">
20
+ <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
21
+ <div class="vr"></div>
22
+ <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
23
+ <div class="vr"></div>
24
+ <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
25
+ <div class="vr"></div>
26
+ <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
27
+ </div>
28
+ </div>
29
+ <div id="applicationInfo" class="container text-center"></div>
30
+ </footer>`));
31
+
32
+ applicationInfo();
33
+ }
34
+
35
+ }
36
+
37
+ function showSimpleError(title) {
38
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
39
+ .append($(`<div class="toast-header bg-danger">
40
+ <strong class="me-auto text-dark">Error</strong>
41
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
42
+ </div>`))
43
+ .append($(`<div class="toast-body"/>`)
44
+ .append($(`<p/>`).text(title))
45
+ );
46
+ $("#notificationPanel").append(notification);
47
+ notification.toast({delay: 30000});
48
+ notification.toast('show');
49
+ }
50
+
51
+ function showError(title, xhr) {
52
+ var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
53
+ var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
54
+ var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
55
+ var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
56
+
57
+ if (xhr.responseJSON && !serverErrorMessage) {
58
+ serverErrorMessage = JSON.stringify(xhr.responseJSON);
59
+ serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
60
+ serverErrorId = `----`;
61
+ }
62
+
63
+ console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
64
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
65
+ .append($(`<div class="toast-header bg-danger">
66
+ <strong class="me-auto text-dark">Error</strong>
67
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
68
+ </div>`))
69
+ .append($(`<div class="toast-body"/>`)
70
+ .append($(`<p/>`).text(title))
71
+ .append($(`<pre/>`)
72
+ .append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
73
+ )
74
+ );
75
+ $("#notificationPanel").append(notification);
76
+ notification.toast({delay: 30000});
77
+ notification.toast('show');
78
+ }
79
+
80
+ // ****************************************************************************
81
+ // Application info
82
+ // ****************************************************************************
83
+
84
+ function applicationInfo() {
85
+ $.getJSON("info", function (info) {
86
+ $("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
87
+ }).fail(function (xhr, ajaxOptions, thrownError) {
88
+ console.warn("Unable to collect application information");
89
+ });
90
+ }
91
+
92
+ // ****************************************************************************
93
+ // TangoColorFactory
94
+ // ****************************************************************************
95
+
96
+ const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
97
+ const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
98
+
99
+ var colorMap = new Map;
100
+ var nextColorCount = 0;
101
+
102
+ function pickColor(object) {
103
+ let color = colorMap[object];
104
+ if (color !== undefined) {
105
+ return color;
106
+ }
107
+ color = nextColor();
108
+ colorMap[object] = color;
109
+ return color;
110
+ }
111
+
112
+ function nextColor() {
113
+ let color;
114
+ let colorIndex = nextColorCount % SEQUENCE_1.length;
115
+ let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
116
+ if (shadeIndex === 0) {
117
+ color = SEQUENCE_1[colorIndex];
118
+ } else if (shadeIndex === 1) {
119
+ color = SEQUENCE_2[colorIndex];
120
+ } else {
121
+ shadeIndex -= 3;
122
+ let floorColor = SEQUENCE_2[colorIndex];
123
+ let ceilColor = SEQUENCE_1[colorIndex];
124
+ let base = Math.floor((shadeIndex / 2) + 1);
125
+ let divisor = 2;
126
+ while (base >= divisor) {
127
+ divisor *= 2;
128
+ }
129
+ base = (base * 2) - divisor + 1;
130
+ let shadePercentage = base / divisor;
131
+ color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
132
+ }
133
+ nextColorCount++;
134
+ return "#" + color.toString(16);
135
+ }
136
+
137
+ function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
138
+ let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
139
+ let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
140
+ let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
141
+ return red | green | blue;
142
+ }
static/webjars/timefold/css/timefold-webui.css ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Keep in sync with .navbar height on a large screen. */
3
+ --ts-navbar-height: 109px;
4
+
5
+ --ts-violet-1-rgb: #3E00FF;
6
+ --ts-violet-2-rgb: #3423A6;
7
+ --ts-violet-3-rgb: #2E1760;
8
+ --ts-violet-4-rgb: #200F4F;
9
+ --ts-violet-5-rgb: #000000; /* TODO FIXME */
10
+ --ts-violet-dark-1-rgb: #b6adfd;
11
+ --ts-violet-dark-2-rgb: #c1bbfd;
12
+ --ts-gray-rgb: #666666;
13
+ --ts-white-rgb: #FFFFFF;
14
+ --ts-light-rgb: #F2F2F2;
15
+ --ts-gray-border: #c5c5c5;
16
+
17
+ --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
18
+ --bs-body-bg: var(--ts-light-rgb); /* link to html bg */
19
+ --bs-link-color: var(--ts-violet-1-rgb);
20
+ --bs-link-hover-color: var(--ts-violet-2-rgb);
21
+
22
+ --bs-navbar-color: var(--ts-white-rgb);
23
+ --bs-navbar-hover-color: var(--ts-white-rgb);
24
+ --bs-nav-link-font-size: 18px;
25
+ --bs-nav-link-font-weight: 400;
26
+ --bs-nav-link-color: var(--ts-white-rgb);
27
+ --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
28
+ }
29
+ .btn {
30
+ --bs-btn-border-radius: 1.5rem;
31
+ }
32
+ .btn-primary {
33
+ --bs-btn-bg: var(--ts-violet-1-rgb);
34
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
35
+ --bs-btn-hover-bg: var(--ts-violet-2-rgb);
36
+ --bs-btn-hover-border-color: var(--ts-violet-2-rgb);
37
+ --bs-btn-active-bg: var(--ts-violet-2-rgb);
38
+ --bs-btn-active-border-bg: var(--ts-violet-2-rgb);
39
+ --bs-btn-disabled-bg: var(--ts-violet-1-rgb);
40
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
41
+ }
42
+ .btn-outline-primary {
43
+ --bs-btn-color: var(--ts-violet-1-rgb);
44
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
45
+ --bs-btn-hover-bg: var(--ts-violet-1-rgb);
46
+ --bs-btn-hover-border-color: var(--ts-violet-1-rgb);
47
+ --bs-btn-active-bg: var(--ts-violet-1-rgb);
48
+ --bs-btn-active-border-color: var(--ts-violet-1-rgb);
49
+ --bs-btn-disabled-color: var(--ts-violet-1-rgb);
50
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
51
+ }
52
+ .navbar-dark {
53
+ --bs-link-color: var(--ts-violet-dark-1-rgb);
54
+ --bs-link-hover-color: var(--ts-violet-dark-2-rgb);
55
+ --bs-navbar-color: var(--ts-white-rgb);
56
+ --bs-navbar-hover-color: var(--ts-white-rgb);
57
+ }
58
+ .nav-pills {
59
+ --bs-nav-pills-link-active-bg: var(--ts-violet-1-rgb);
60
+ }
static/webjars/timefold/img/timefold-favicon.svg ADDED
static/webjars/timefold/img/timefold-logo-horizontal-negative.svg ADDED
static/webjars/timefold/img/timefold-logo-horizontal-positive.svg ADDED
static/webjars/timefold/img/timefold-logo-stacked-positive.svg ADDED
static/webjars/timefold/js/timefold-webui.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function replaceTimefoldAutoHeaderFooter() {
2
+ const timefoldHeader = $("header#timefold-auto-header");
3
+ if (timefoldHeader != null) {
4
+ timefoldHeader.addClass("bg-black")
5
+ timefoldHeader.append(
6
+ $(`<div class="container-fluid">
7
+ <nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
8
+ <a class="navbar-brand" href="https://timefold.ai">
9
+ <img src="/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200">
10
+ </a>
11
+ </nav>
12
+ </div>`));
13
+ }
14
+ const timefoldFooter = $("footer#timefold-auto-footer");
15
+ if (timefoldFooter != null) {
16
+ timefoldFooter.append(
17
+ $(`<footer class="bg-black text-white-50">
18
+ <div class="container">
19
+ <div class="hstack gap-3 p-4">
20
+ <div class="ms-auto"><a class="text-white" href="https://timefold.ai">Timefold</a></div>
21
+ <div class="vr"></div>
22
+ <div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div>
23
+ <div class="vr"></div>
24
+ <div><a class="text-white" href="https://github.com/TimefoldAI/timefold-solver-python">Code</a></div>
25
+ <div class="vr"></div>
26
+ <div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">Support</a></div>
27
+ </div>
28
+ </div>
29
+ <div id="applicationInfo" class="container text-center"></div>
30
+ </footer>`));
31
+
32
+ applicationInfo();
33
+ }
34
+
35
+ }
36
+
37
+ function showSimpleError(title) {
38
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
39
+ .append($(`<div class="toast-header bg-danger">
40
+ <strong class="me-auto text-dark">Error</strong>
41
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
42
+ </div>`))
43
+ .append($(`<div class="toast-body"/>`)
44
+ .append($(`<p/>`).text(title))
45
+ );
46
+ $("#notificationPanel").append(notification);
47
+ notification.toast({delay: 30000});
48
+ notification.toast('show');
49
+ }
50
+
51
+ function showError(title, xhr) {
52
+ var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
53
+ var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
54
+ var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
55
+ var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
56
+
57
+ if (xhr.responseJSON && !serverErrorMessage) {
58
+ serverErrorMessage = JSON.stringify(xhr.responseJSON);
59
+ serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
60
+ serverErrorId = `----`;
61
+ }
62
+
63
+ console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
64
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
65
+ .append($(`<div class="toast-header bg-danger">
66
+ <strong class="me-auto text-dark">Error</strong>
67
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
68
+ </div>`))
69
+ .append($(`<div class="toast-body"/>`)
70
+ .append($(`<p/>`).text(title))
71
+ .append($(`<pre/>`)
72
+ .append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
73
+ )
74
+ );
75
+ $("#notificationPanel").append(notification);
76
+ notification.toast({delay: 30000});
77
+ notification.toast('show');
78
+ }
79
+
80
+ // ****************************************************************************
81
+ // Application info
82
+ // ****************************************************************************
83
+
84
+ function applicationInfo() {
85
+ $.getJSON("info", function (info) {
86
+ $("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
87
+ }).fail(function (xhr, ajaxOptions, thrownError) {
88
+ console.warn("Unable to collect application information");
89
+ });
90
+ }
91
+
92
+ // ****************************************************************************
93
+ // TangoColorFactory
94
+ // ****************************************************************************
95
+
96
+ const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
97
+ const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
98
+
99
+ var colorMap = new Map;
100
+ var nextColorCount = 0;
101
+
102
+ function pickColor(object) {
103
+ let color = colorMap[object];
104
+ if (color !== undefined) {
105
+ return color;
106
+ }
107
+ color = nextColor();
108
+ colorMap[object] = color;
109
+ return color;
110
+ }
111
+
112
+ function nextColor() {
113
+ let color;
114
+ let colorIndex = nextColorCount % SEQUENCE_1.length;
115
+ let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
116
+ if (shadeIndex === 0) {
117
+ color = SEQUENCE_1[colorIndex];
118
+ } else if (shadeIndex === 1) {
119
+ color = SEQUENCE_2[colorIndex];
120
+ } else {
121
+ shadeIndex -= 3;
122
+ let floorColor = SEQUENCE_2[colorIndex];
123
+ let ceilColor = SEQUENCE_1[colorIndex];
124
+ let base = Math.floor((shadeIndex / 2) + 1);
125
+ let divisor = 2;
126
+ while (base >= divisor) {
127
+ divisor *= 2;
128
+ }
129
+ base = (base * 2) - divisor + 1;
130
+ let shadePercentage = base / divisor;
131
+ color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
132
+ }
133
+ nextColorCount++;
134
+ return "#" + color.toString(16);
135
+ }
136
+
137
+ function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
138
+ let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
139
+ let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
140
+ let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
141
+ return red | green | blue;
142
+ }
tests/test_constraints.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver.test import ConstraintVerifier
2
+
3
+ from order_picking.domain import (
4
+ Product, Order, OrderItem, Trolley, TrolleyStep, OrderPickingSolution
5
+ )
6
+ from order_picking.warehouse import WarehouseLocation, Side, new_shelving_id, Column, Row
7
+ from order_picking.constraints import (
8
+ define_constraints,
9
+ required_number_of_buckets,
10
+ minimize_order_split_by_trolley,
11
+ minimize_distance_from_previous_step,
12
+ minimize_distance_from_last_step_to_origin,
13
+ )
14
+
15
+
16
+ # Test locations
17
+ LOCATION_A1_LEFT_5 = WarehouseLocation(
18
+ shelving_id=new_shelving_id(Column.COL_A, Row.ROW_1),
19
+ side=Side.LEFT,
20
+ row=5
21
+ )
22
+ LOCATION_A1_LEFT_8 = WarehouseLocation(
23
+ shelving_id=new_shelving_id(Column.COL_A, Row.ROW_1),
24
+ side=Side.LEFT,
25
+ row=8
26
+ )
27
+ LOCATION_B1_LEFT_3 = WarehouseLocation(
28
+ shelving_id=new_shelving_id(Column.COL_B, Row.ROW_1),
29
+ side=Side.LEFT,
30
+ row=3
31
+ )
32
+ LOCATION_E3_RIGHT_9 = WarehouseLocation(
33
+ shelving_id=new_shelving_id(Column.COL_E, Row.ROW_3),
34
+ side=Side.RIGHT,
35
+ row=9
36
+ )
37
+
38
+ # Bucket capacity for tests (48,000 cm3)
39
+ BUCKET_CAPACITY = 48000
40
+
41
+
42
+ constraint_verifier = ConstraintVerifier.build(
43
+ define_constraints, OrderPickingSolution, Trolley, TrolleyStep
44
+ )
45
+
46
+
47
+ def create_product(id: str, volume: int, location: WarehouseLocation) -> Product:
48
+ return Product(id=id, name=f"Product {id}", volume=volume, location=location)
49
+
50
+
51
+ def create_order_with_items(order_id: str, products: list[Product]) -> tuple[Order, list[OrderItem]]:
52
+ order = Order(id=order_id, items=[])
53
+ items = []
54
+ for i, product in enumerate(products):
55
+ item = OrderItem(id=f"{order_id}-{i}", order=order, product=product)
56
+ order.items.append(item)
57
+ items.append(item)
58
+ return order, items
59
+
60
+
61
+ def connect(trolley: Trolley, *steps: TrolleyStep):
62
+ """Set up trolley-step relationships."""
63
+ trolley.steps = list(steps)
64
+ for i, step in enumerate(steps):
65
+ step.trolley = trolley
66
+ step.previous_step = steps[i - 1] if i > 0 else None
67
+ step.next_step = steps[i + 1] if i < len(steps) - 1 else None
68
+
69
+
70
+ class TestRequiredNumberOfBuckets:
71
+ def test_not_penalized_when_under_capacity(self):
72
+ """Trolley with enough buckets should not be penalized."""
73
+ trolley = Trolley(
74
+ id="1",
75
+ bucket_count=4,
76
+ bucket_capacity=BUCKET_CAPACITY,
77
+ location=LOCATION_A1_LEFT_5
78
+ )
79
+ product = create_product("p1", 10000, LOCATION_B1_LEFT_3)
80
+ order, items = create_order_with_items("o1", [product])
81
+ step = TrolleyStep(id="s1", order_item=items[0])
82
+
83
+ connect(trolley, step)
84
+
85
+ constraint_verifier.verify_that(required_number_of_buckets).given(
86
+ trolley, step
87
+ ).penalizes_by(0)
88
+
89
+ def test_penalized_when_over_bucket_count(self):
90
+ """Trolley with too few buckets should be penalized."""
91
+ trolley = Trolley(
92
+ id="1",
93
+ bucket_count=1, # Only 1 bucket
94
+ bucket_capacity=BUCKET_CAPACITY,
95
+ location=LOCATION_A1_LEFT_5
96
+ )
97
+ # Create order with volume requiring 2 buckets
98
+ product1 = create_product("p1", 40000, LOCATION_B1_LEFT_3)
99
+ product2 = create_product("p2", 40000, LOCATION_A1_LEFT_8)
100
+ order, items = create_order_with_items("o1", [product1, product2])
101
+ step1 = TrolleyStep(id="s1", order_item=items[0])
102
+ step2 = TrolleyStep(id="s2", order_item=items[1])
103
+
104
+ connect(trolley, step1, step2)
105
+
106
+ # Total volume: 80000, bucket capacity: 48000
107
+ # Required buckets: ceil(80000/48000) = 2
108
+ # Available: 1, excess: 1
109
+ constraint_verifier.verify_that(required_number_of_buckets).given(
110
+ trolley, step1, step2
111
+ ).penalizes_by(1)
112
+
113
+
114
+ class TestMinimizeOrderSplitByTrolley:
115
+ def test_single_trolley_per_order(self):
116
+ """Order on single trolley should be minimally penalized."""
117
+ trolley = Trolley(
118
+ id="1",
119
+ bucket_count=4,
120
+ bucket_capacity=BUCKET_CAPACITY,
121
+ location=LOCATION_A1_LEFT_5
122
+ )
123
+ product = create_product("p1", 10000, LOCATION_B1_LEFT_3)
124
+ order, items = create_order_with_items("o1", [product])
125
+ step = TrolleyStep(id="s1", order_item=items[0])
126
+
127
+ connect(trolley, step)
128
+
129
+ # 1 trolley * 1000 = 1000
130
+ constraint_verifier.verify_that(minimize_order_split_by_trolley).given(
131
+ trolley, step
132
+ ).penalizes_by(1000)
133
+
134
+ def test_order_split_across_trolleys(self):
135
+ """Order split across trolleys should be penalized more."""
136
+ trolley1 = Trolley(
137
+ id="1",
138
+ bucket_count=4,
139
+ bucket_capacity=BUCKET_CAPACITY,
140
+ location=LOCATION_A1_LEFT_5
141
+ )
142
+ trolley2 = Trolley(
143
+ id="2",
144
+ bucket_count=4,
145
+ bucket_capacity=BUCKET_CAPACITY,
146
+ location=LOCATION_A1_LEFT_5
147
+ )
148
+ product1 = create_product("p1", 10000, LOCATION_B1_LEFT_3)
149
+ product2 = create_product("p2", 10000, LOCATION_A1_LEFT_8)
150
+ order, items = create_order_with_items("o1", [product1, product2])
151
+ step1 = TrolleyStep(id="s1", order_item=items[0])
152
+ step2 = TrolleyStep(id="s2", order_item=items[1])
153
+
154
+ connect(trolley1, step1)
155
+ connect(trolley2, step2)
156
+
157
+ # 2 trolleys * 1000 = 2000
158
+ constraint_verifier.verify_that(minimize_order_split_by_trolley).given(
159
+ trolley1, trolley2, step1, step2
160
+ ).penalizes_by(2000)
161
+
162
+
163
+ class TestMinimizeDistanceFromPreviousStep:
164
+ def test_distance_from_trolley_start(self):
165
+ """Distance from trolley start location to first step."""
166
+ trolley = Trolley(
167
+ id="1",
168
+ bucket_count=4,
169
+ bucket_capacity=BUCKET_CAPACITY,
170
+ location=LOCATION_A1_LEFT_5
171
+ )
172
+ product = create_product("p1", 10000, LOCATION_A1_LEFT_8)
173
+ order, items = create_order_with_items("o1", [product])
174
+ step = TrolleyStep(id="s1", order_item=items[0])
175
+
176
+ connect(trolley, step)
177
+
178
+ # Same shelving, same side: |8 - 5| = 3 meters
179
+ constraint_verifier.verify_that(minimize_distance_from_previous_step).given(
180
+ trolley, step
181
+ ).penalizes_by(3)
182
+
183
+
184
+ class TestMinimizeDistanceFromLastStepToOrigin:
185
+ def test_distance_back_to_origin(self):
186
+ """Distance from last step back to trolley start location."""
187
+ trolley = Trolley(
188
+ id="1",
189
+ bucket_count=4,
190
+ bucket_capacity=BUCKET_CAPACITY,
191
+ location=LOCATION_A1_LEFT_5
192
+ )
193
+ product = create_product("p1", 10000, LOCATION_A1_LEFT_8)
194
+ order, items = create_order_with_items("o1", [product])
195
+ step = TrolleyStep(id="s1", order_item=items[0])
196
+
197
+ connect(trolley, step)
198
+
199
+ # Same shelving, same side: |8 - 5| = 3 meters
200
+ constraint_verifier.verify_that(minimize_distance_from_last_step_to_origin).given(
201
+ trolley, step
202
+ ).penalizes_by(3)