Spaces:
Sleeping
Sleeping
Upload 31 files
Browse files- Dockerfile +24 -0
- README.md +72 -5
- logging.conf +30 -0
- pyproject.toml +20 -0
- src/order_picking/__init__.py +19 -0
- src/order_picking/constraints.py +68 -0
- src/order_picking/converters.py +185 -0
- src/order_picking/demo_data.py +258 -0
- src/order_picking/domain.py +286 -0
- src/order_picking/json_serialization.py +47 -0
- src/order_picking/rest_api.py +289 -0
- src/order_picking/solver.py +23 -0
- src/order_picking/warehouse.py +165 -0
- static/app.js +653 -0
- static/index.html +179 -0
- static/logisim-view.js +850 -0
- static/style.css +810 -0
- static/warehouse-api.js +371 -0
- static/webjars/solverforge/css/solverforge-webui.css +68 -0
- static/webjars/solverforge/img/solverforge-favicon.svg +65 -0
- static/webjars/solverforge/img/solverforge-horizontal-white.svg +66 -0
- static/webjars/solverforge/img/solverforge-horizontal.svg +65 -0
- static/webjars/solverforge/img/solverforge-logo-stacked.svg +73 -0
- static/webjars/solverforge/js/solverforge-webui.js +142 -0
- static/webjars/timefold/css/timefold-webui.css +60 -0
- static/webjars/timefold/img/timefold-favicon.svg +25 -0
- static/webjars/timefold/img/timefold-logo-horizontal-negative.svg +1 -0
- static/webjars/timefold/img/timefold-logo-horizontal-positive.svg +1 -0
- static/webjars/timefold/img/timefold-logo-stacked-positive.svg +1 -0
- static/webjars/timefold/js/timefold-webui.js +142 -0
- tests/test_constraints.py +202 -0
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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
license: apache-2.0
|
| 9 |
short_description: SolverForge Quickstart for the Order Picking problem
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|