feat: Implement initial structure for Directus Mayo API extension
- Added main router in src/index.js to register endpoints. - Implemented GET /mayo-api/products to fetch product list with pagination and filters. - Implemented GET /mayo-api/dictionaries to fetch various dictionaries for frontend use. - Created separate files for routes, repositories, serializers, and utilities to maintain clean architecture. - Added utility functions for async handling, pagination, and order search parsing. - Introduced serializers for products and dictionaries to format data for frontend consumption. - Established repository functions for database queries related to products and dictionaries. - Updated package.json to include license information. - Created documentation for the API extension detailing current state and future implementation plans.
This commit is contained in:
206
db_schema.dbml
206
db_schema.dbml
@@ -1,76 +1,52 @@
|
|||||||
// Directus Database Schema for Mayo
|
// Directus Database Schema for Mayo
|
||||||
// Generated from snapshot(6).json
|
// Revised schema proposal
|
||||||
|
// Updated: 2026-05-20
|
||||||
Enum mayo_models_neck_construction {
|
|
||||||
NTB
|
|
||||||
BOLT_ON
|
|
||||||
SET_IN
|
|
||||||
}
|
|
||||||
|
|
||||||
Enum mayo_parts_part_type {
|
|
||||||
BODY
|
|
||||||
NECK
|
|
||||||
}
|
|
||||||
|
|
||||||
Enum mayo_parts_finish {
|
|
||||||
GLOSS
|
|
||||||
SATIN
|
|
||||||
MAT
|
|
||||||
NITRO
|
|
||||||
}
|
|
||||||
|
|
||||||
Table mayo_clients {
|
Table mayo_clients {
|
||||||
id integer [primary key]
|
id integer [primary key]
|
||||||
name "character varying" [unique, not null]
|
name "character varying" [unique, not null]
|
||||||
country "character varying" [not null]
|
country "character varying"
|
||||||
}
|
}
|
||||||
|
|
||||||
Table mayo_color {
|
Table mayo_colors {
|
||||||
id integer [primary key]
|
id integer [primary key]
|
||||||
name "character varying" [unique, not null]
|
name "character varying" [unique, not null]
|
||||||
}
|
}
|
||||||
|
|
||||||
Table mayo_lisst_products {
|
Table mayo_finishes {
|
||||||
|
id integer [primary key]
|
||||||
|
code "character varying" [unique, not null]
|
||||||
|
name "character varying" [not null]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table mayo_neck_constructions {
|
||||||
|
id integer [primary key]
|
||||||
|
code "character varying" [unique, not null]
|
||||||
|
name "character varying" [not null]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table mayo_part_types {
|
||||||
|
id integer [primary key]
|
||||||
|
code "character varying" [unique, not null]
|
||||||
|
name "character varying" [not null]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table mayo_product_models {
|
||||||
|
id integer [primary key]
|
||||||
|
name "character varying" [unique, not null]
|
||||||
|
neck_construction_id integer [not null]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table mayo_products {
|
||||||
id integer [primary key]
|
id integer [primary key]
|
||||||
user_created uuid
|
user_created uuid
|
||||||
date_created timestamp
|
date_created timestamp
|
||||||
user_updated uuid
|
user_updated uuid
|
||||||
date_updated timestamp
|
date_updated timestamp
|
||||||
product_id integer [not null]
|
source_url "character varying"
|
||||||
list_id integer [unique, not null]
|
specification json
|
||||||
}
|
specification_last_fetched_at timestamp
|
||||||
|
product_model_id integer [not null]
|
||||||
Table mayo_lists {
|
|
||||||
id integer [primary key]
|
|
||||||
user_created uuid
|
|
||||||
date_created timestamp
|
|
||||||
user_updated uuid
|
|
||||||
date_updated timestamp
|
|
||||||
name "character varying" [unique, not null]
|
|
||||||
description "character varying"
|
|
||||||
}
|
|
||||||
|
|
||||||
Table mayo_models {
|
|
||||||
id integer [primary key]
|
|
||||||
name "character varying" [unique, not null]
|
|
||||||
neck_construction mayo_models_neck_construction [not null]
|
|
||||||
}
|
|
||||||
|
|
||||||
Table mayo_operations {
|
|
||||||
id integer [primary key]
|
|
||||||
name "character varying" [unique, not null]
|
|
||||||
description text
|
|
||||||
}
|
|
||||||
|
|
||||||
Table mayo_order_porducts {
|
|
||||||
id integer [primary key]
|
|
||||||
user_created uuid
|
|
||||||
date_created timestamp
|
|
||||||
user_updated uuid
|
|
||||||
date_updated timestamp
|
|
||||||
product_id integer [unique, not null]
|
|
||||||
order_id integer [not null]
|
|
||||||
order_index integer [not null]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Table mayo_orders {
|
Table mayo_orders {
|
||||||
@@ -84,49 +60,97 @@ Table mayo_orders {
|
|||||||
client_id integer [not null]
|
client_id integer [not null]
|
||||||
}
|
}
|
||||||
|
|
||||||
Table mayo_part_events {
|
Table mayo_order_items {
|
||||||
id integer [primary key]
|
id integer [primary key]
|
||||||
user_created uuid
|
user_created uuid
|
||||||
date_created timestamp
|
date_created timestamp
|
||||||
user_updated uuid
|
user_updated uuid
|
||||||
date_updated timestamp
|
date_updated timestamp
|
||||||
part_id integer [not null]
|
product_id integer [unique, not null]
|
||||||
|
order_id integer [not null]
|
||||||
|
order_index integer [not null]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table mayo_production_lists {
|
||||||
|
id integer [primary key]
|
||||||
|
user_created uuid
|
||||||
|
date_created timestamp
|
||||||
|
user_updated uuid
|
||||||
|
date_updated timestamp
|
||||||
|
name "character varying" [unique, not null]
|
||||||
|
description text
|
||||||
|
}
|
||||||
|
|
||||||
|
Table mayo_product_production_lists {
|
||||||
|
id integer [primary key]
|
||||||
|
user_created uuid
|
||||||
|
date_created timestamp
|
||||||
|
user_updated uuid
|
||||||
|
date_updated timestamp
|
||||||
|
order_item_id integer [not null]
|
||||||
|
production_list_id integer [not null]
|
||||||
|
}
|
||||||
|
|
||||||
|
Table mayo_product_parts {
|
||||||
|
id integer [primary key]
|
||||||
|
product_id integer [not null]
|
||||||
|
part_type_id integer [not null]
|
||||||
|
top_color_id integer
|
||||||
|
back_color_id integer
|
||||||
|
top_finish_id integer
|
||||||
|
back_finish_id integer
|
||||||
|
}
|
||||||
|
|
||||||
|
Table mayo_production_operations {
|
||||||
|
id integer [primary key]
|
||||||
|
code "character varying" [unique, not null]
|
||||||
|
name "character varying" [unique, not null]
|
||||||
|
description text
|
||||||
|
}
|
||||||
|
|
||||||
|
Table mayo_production_events {
|
||||||
|
id integer [primary key]
|
||||||
|
user_created uuid
|
||||||
|
date_created timestamp
|
||||||
|
user_updated uuid
|
||||||
|
date_updated timestamp
|
||||||
|
product_part_id integer [not null]
|
||||||
ordinal integer [not null]
|
ordinal integer [not null]
|
||||||
date date [not null]
|
event_date date [not null]
|
||||||
operation_id integer
|
operation_id integer
|
||||||
note text
|
note text
|
||||||
}
|
}
|
||||||
|
|
||||||
Table mayo_parts {
|
|
||||||
id integer [primary key]
|
|
||||||
part_type mayo_parts_part_type [not null]
|
|
||||||
top_color_id integer [not null]
|
|
||||||
back_color_id integer [not null]
|
|
||||||
top_finish mayo_parts_finish [not null]
|
|
||||||
back_finish mayo_parts_finish [not null]
|
|
||||||
product_id integer [not null]
|
|
||||||
}
|
|
||||||
|
|
||||||
Table mayo_products {
|
|
||||||
id integer [primary key]
|
|
||||||
user_created uuid
|
|
||||||
date_created timestamp
|
|
||||||
user_updated uuid
|
|
||||||
date_updated timestamp
|
|
||||||
source_url "character varying"
|
|
||||||
specification json
|
|
||||||
model_id integer [not null]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
Ref: mayo_lisst_products.product_id > mayo_products.id
|
Ref: mayo_product_models.neck_construction_id > mayo_neck_constructions.id
|
||||||
Ref: mayo_lisst_products.list_id > mayo_lists.id
|
Ref: mayo_products.product_model_id > mayo_product_models.id
|
||||||
Ref: mayo_order_porducts.product_id - mayo_products.id
|
|
||||||
Ref: mayo_order_porducts.order_id > mayo_orders.id
|
|
||||||
Ref: mayo_orders.client_id > mayo_clients.id
|
Ref: mayo_orders.client_id > mayo_clients.id
|
||||||
Ref: mayo_part_events.part_id > mayo_parts.id
|
Ref: mayo_order_items.product_id - mayo_products.id
|
||||||
Ref: mayo_part_events.operation_id > mayo_operations.id
|
Ref: mayo_order_items.order_id > mayo_orders.id
|
||||||
Ref: mayo_parts.top_color_id > mayo_color.id
|
Ref: mayo_product_production_lists.order_item_id > mayo_order_items.id
|
||||||
Ref: mayo_parts.back_color_id > mayo_color.id
|
Ref: mayo_product_production_lists.production_list_id > mayo_production_lists.id
|
||||||
Ref: mayo_parts.product_id > mayo_products.id
|
Ref: mayo_product_parts.product_id > mayo_products.id
|
||||||
Ref: mayo_products.model_id > mayo_models.id
|
Ref: mayo_product_parts.part_type_id > mayo_part_types.id
|
||||||
|
Ref: mayo_product_parts.top_color_id > mayo_colors.id
|
||||||
|
Ref: mayo_product_parts.back_color_id > mayo_colors.id
|
||||||
|
Ref: mayo_product_parts.top_finish_id > mayo_finishes.id
|
||||||
|
Ref: mayo_product_parts.back_finish_id > mayo_finishes.id
|
||||||
|
Ref: mayo_production_events.product_part_id > mayo_product_parts.id
|
||||||
|
Ref: mayo_production_events.operation_id > mayo_production_operations.id
|
||||||
|
|
||||||
|
// Suggested seed values
|
||||||
|
//
|
||||||
|
// mayo_neck_constructions:
|
||||||
|
// - NTB: Neck-through-body
|
||||||
|
// - BOLT_ON: Bolt-on
|
||||||
|
// - SET_IN: Set-in
|
||||||
|
//
|
||||||
|
// mayo_part_types:
|
||||||
|
// - BODY: Body
|
||||||
|
// - NECK: Neck
|
||||||
|
//
|
||||||
|
// mayo_finishes:
|
||||||
|
// - GLOSS: Gloss
|
||||||
|
// - SATIN: Satin
|
||||||
|
// - MAT: Mat
|
||||||
|
// - NITRO: Nitro
|
||||||
|
|||||||
16
directus/extensions/directus-extension-mayo-api/README.md
Normal file
16
directus/extensions/directus-extension-mayo-api/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Directus Mayo API Extension
|
||||||
|
|
||||||
|
Custom Directus endpoint extension for Duck Prod Manager.
|
||||||
|
|
||||||
|
The extension exposes frontend-facing API routes under:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/mayo-api
|
||||||
|
```
|
||||||
|
|
||||||
|
Current implemented routes:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET /mayo-api/products
|
||||||
|
GET /mayo-api/dictionaries
|
||||||
|
```
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
@baseUrl = https://bartool.ovh/dpm-api
|
||||||
|
@token = _gEp4JyW6uBY7xyKVDo2vKUtCLfPGr3D
|
||||||
|
|
||||||
|
### Health check - Directus server
|
||||||
|
GET {{baseUrl}}/server/health
|
||||||
|
|
||||||
|
### Logowanie
|
||||||
|
# @name login
|
||||||
|
POST {{baseUrl}}/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "bartoolina@gmail.com",
|
||||||
|
"password": "20madafaka"
|
||||||
|
}
|
||||||
|
|
||||||
|
###
|
||||||
|
@auth_token = {{login.response.body.data.access_token}}
|
||||||
|
|
||||||
|
|
||||||
|
### Mayo API - products
|
||||||
|
GET {{baseUrl}}/mayo-api/products
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
|
||||||
|
### Mayo API - products with pagination
|
||||||
|
GET {{baseUrl}}/mayo-api/products?limit=30&offset=0
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
|
||||||
|
### Mayo API - products by order search
|
||||||
|
# orderSearch is a JSON string. This example matches order number 0143/2025/1.
|
||||||
|
GET {{baseUrl}}/mayo-api/products?search=0143/2025/1&orderSearch=[{"raw":"0143/2025/1","type":"fullOrderId","match":"exact","orderNumber":143,"orderNumberPrefix":null,"orderYear":2025,"orderIndex":1}]
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
|
||||||
|
### Mayo API - products by year
|
||||||
|
GET {{baseUrl}}/mayo-api/products?year=2025
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
|
||||||
|
### Mayo API - products by model id
|
||||||
|
GET {{baseUrl}}/mayo-api/products?model=2
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
|
||||||
|
### Mayo API - products by client id
|
||||||
|
GET {{baseUrl}}/mayo-api/products?client=1
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
|
||||||
|
### Mayo API - products by production list id
|
||||||
|
GET {{baseUrl}}/mayo-api/products?productionList=1
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
|
||||||
|
### Mayo API - products by finish
|
||||||
|
GET {{baseUrl}}/mayo-api/products?finish=MAT
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
|
||||||
|
### Mayo API - dictionaries
|
||||||
|
GET {{baseUrl}}/mayo-api/dictionaries
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
252
directus/extensions/directus-extension-mayo-api/old/index.js.old
Normal file
252
directus/extensions/directus-extension-mayo-api/old/index.js.old
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
export default {
|
||||||
|
id: "mayo-api",
|
||||||
|
handler: (router, context) => {
|
||||||
|
const { services, getSchema, database } = context;
|
||||||
|
const { ItemsService } = services;
|
||||||
|
|
||||||
|
router.get("/products", async (req, res) => {
|
||||||
|
const productsService = new ItemsService("mayo_products", {
|
||||||
|
schema: await getSchema(),
|
||||||
|
accountability: req.accountability,
|
||||||
|
});
|
||||||
|
const partsService = new ItemsService("mayo_parts", {
|
||||||
|
schema: await getSchema(),
|
||||||
|
accountability: req.accountability,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const products = await productsService.readByQuery({
|
||||||
|
fields: [
|
||||||
|
"id",
|
||||||
|
"note",
|
||||||
|
"model_id.name",
|
||||||
|
"model_id.strings_count",
|
||||||
|
"model_id.scale",
|
||||||
|
],
|
||||||
|
limit: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const productIds = products.map((product) => product.id);
|
||||||
|
|
||||||
|
const parts = await partsService.readByQuery({
|
||||||
|
fields: [
|
||||||
|
"id",
|
||||||
|
"product_id",
|
||||||
|
"part_type",
|
||||||
|
"top_color_id.name",
|
||||||
|
"back_color_id.name",
|
||||||
|
"top_finish",
|
||||||
|
"back_finish",
|
||||||
|
],
|
||||||
|
filter: {
|
||||||
|
product_id: {
|
||||||
|
_in: productIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: -1,
|
||||||
|
});
|
||||||
|
const partsByProductId = new Map();
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const productId =
|
||||||
|
typeof part.product_id === "object"
|
||||||
|
? part.product_id.id
|
||||||
|
: part.product_id;
|
||||||
|
|
||||||
|
if (!partsByProductId.has(productId)) {
|
||||||
|
partsByProductId.set(productId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
partsByProductId.get(productId).push(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = products.map((product) => ({
|
||||||
|
...product,
|
||||||
|
parts: partsByProductId.get(product.id) ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/products-new", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const products = await database("mayo_products as product")
|
||||||
|
.select(
|
||||||
|
"product.id",
|
||||||
|
"product.note",
|
||||||
|
"model.name as model_name",
|
||||||
|
"model.strings_count as model_strings_count",
|
||||||
|
"model.scale as model_scale",
|
||||||
|
)
|
||||||
|
.leftJoin("mayo_models as model", "product.model_id", "model.id");
|
||||||
|
|
||||||
|
const productIds = products.map((product) => product.id);
|
||||||
|
|
||||||
|
const parts = productIds.length
|
||||||
|
? await database("mayo_parts as part")
|
||||||
|
.select(
|
||||||
|
"part.id",
|
||||||
|
"part.product_id",
|
||||||
|
"part.part_type",
|
||||||
|
"part.top_finish",
|
||||||
|
"part.back_finish",
|
||||||
|
"top_color.name as top_color_name",
|
||||||
|
"back_color.name as back_color_name",
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
"mayo_colors as top_color",
|
||||||
|
"part.top_color_id",
|
||||||
|
"top_color.id",
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
"mayo_colors as back_color",
|
||||||
|
"part.back_color_id",
|
||||||
|
"back_color.id",
|
||||||
|
)
|
||||||
|
.whereIn("part.product_id", productIds)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const partsByProductId = new Map();
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!partsByProductId.has(part.product_id)) {
|
||||||
|
partsByProductId.set(part.product_id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
partsByProductId.get(part.product_id).push({
|
||||||
|
id: part.id,
|
||||||
|
product_id: part.product_id,
|
||||||
|
part_type: part.part_type,
|
||||||
|
top_color_id: {
|
||||||
|
name: part.top_color_name,
|
||||||
|
},
|
||||||
|
back_color_id: {
|
||||||
|
name: part.back_color_name,
|
||||||
|
},
|
||||||
|
top_finish: part.top_finish,
|
||||||
|
back_finish: part.back_finish,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = products.map((product) => ({
|
||||||
|
id: product.id,
|
||||||
|
note: product.note,
|
||||||
|
model_id: {
|
||||||
|
name: product.model_name,
|
||||||
|
strings_count: product.model_strings_count,
|
||||||
|
scale: product.model_scale,
|
||||||
|
},
|
||||||
|
parts: partsByProductId.get(product.id) ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/orders", async (req, res) => {
|
||||||
|
const itemsService = new ItemsService("mayo_order_products", {
|
||||||
|
schema: await getSchema(),
|
||||||
|
accountability: req.accountability,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await itemsService.readByQuery({
|
||||||
|
fields: [
|
||||||
|
"id",
|
||||||
|
"product_order_index",
|
||||||
|
"order_id.order_number",
|
||||||
|
"order_id.order_year",
|
||||||
|
"order_id.client_id.name",
|
||||||
|
"product_id.id",
|
||||||
|
"product_id.note",
|
||||||
|
"product_id.model_id.name",
|
||||||
|
"product_id.model_id.strings_count",
|
||||||
|
"product_id.model_id.scale",
|
||||||
|
],
|
||||||
|
limit: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(items);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/my-products", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const products = await database("mayo_products as mp")
|
||||||
|
.select(
|
||||||
|
"mp.id",
|
||||||
|
"mm.name as model",
|
||||||
|
"mo.order_number",
|
||||||
|
"mo.order_year",
|
||||||
|
"mop.product_order_index as order_index",
|
||||||
|
)
|
||||||
|
.leftJoin("mayo_order_products as mop", "mop.product_id", "mp.id")
|
||||||
|
.leftJoin("mayo_orders as mo", "mo.id", "mop.order_id")
|
||||||
|
.leftJoin("mayo_models as mm", "mm.id", "mp.model_id");
|
||||||
|
|
||||||
|
const productIds = products.map((product) => product.id);
|
||||||
|
|
||||||
|
const parts = productIds.length
|
||||||
|
? await database("mayo_parts as part")
|
||||||
|
.select("part.product_id", "part.part_type")
|
||||||
|
.whereIn("part.product_id", productIds)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const partTypesByProductId = new Map();
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!partTypesByProductId.has(part.product_id)) {
|
||||||
|
partTypesByProductId.set(part.product_id, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.part_type) {
|
||||||
|
partTypesByProductId.get(part.product_id).push(part.part_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = products.map((product) => ({
|
||||||
|
...product,
|
||||||
|
part_types: partTypesByProductId.get(product.id) ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/my-products2", async (req, res) => {
|
||||||
|
const productsService = new ItemsService("mayo_products", {
|
||||||
|
schema: await getSchema(),
|
||||||
|
accountability: req.accountability,
|
||||||
|
});
|
||||||
|
const partsService = new ItemsService("mayo_parts", {
|
||||||
|
schema: await getSchema(),
|
||||||
|
accountability: req.accountability,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const products = await productsService.readByQuery({
|
||||||
|
fields: [
|
||||||
|
"id",
|
||||||
|
"note",
|
||||||
|
"model_id.name",
|
||||||
|
"model_id.strings_count",
|
||||||
|
"model_id.scale",
|
||||||
|
],
|
||||||
|
limit: -1,
|
||||||
|
});
|
||||||
|
res.json(products);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"description": "Please enter a description for your extension",
|
"description": "Please enter a description for your extension",
|
||||||
"icon": "extension",
|
"icon": "extension",
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
|
"license": "UNLICENSED",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"directus",
|
"directus",
|
||||||
"directus-extension",
|
"directus-extension",
|
||||||
|
|||||||
@@ -1,252 +1,11 @@
|
|||||||
|
import { registerGetDictionaries } from './routes/dictionaries.get.js'
|
||||||
|
import { registerGetProducts } from './routes/products.get.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
id: "mayo-api",
|
id: 'mayo-api',
|
||||||
|
|
||||||
handler: (router, context) => {
|
handler: (router, context) => {
|
||||||
const { services, getSchema, database } = context;
|
registerGetProducts(router, context)
|
||||||
const { ItemsService } = services;
|
registerGetDictionaries(router, context)
|
||||||
|
|
||||||
router.get("/products", async (req, res) => {
|
|
||||||
const productsService = new ItemsService("mayo_products", {
|
|
||||||
schema: await getSchema(),
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
const partsService = new ItemsService("mayo_parts", {
|
|
||||||
schema: await getSchema(),
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const products = await productsService.readByQuery({
|
|
||||||
fields: [
|
|
||||||
"id",
|
|
||||||
"note",
|
|
||||||
"model_id.name",
|
|
||||||
"model_id.strings_count",
|
|
||||||
"model_id.scale",
|
|
||||||
],
|
|
||||||
limit: -1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const productIds = products.map((product) => product.id);
|
|
||||||
|
|
||||||
const parts = await partsService.readByQuery({
|
|
||||||
fields: [
|
|
||||||
"id",
|
|
||||||
"product_id",
|
|
||||||
"part_type",
|
|
||||||
"top_color_id.name",
|
|
||||||
"back_color_id.name",
|
|
||||||
"top_finish",
|
|
||||||
"back_finish",
|
|
||||||
],
|
|
||||||
filter: {
|
|
||||||
product_id: {
|
|
||||||
_in: productIds,
|
|
||||||
},
|
},
|
||||||
},
|
|
||||||
limit: -1,
|
|
||||||
});
|
|
||||||
const partsByProductId = new Map();
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
const productId =
|
|
||||||
typeof part.product_id === "object"
|
|
||||||
? part.product_id.id
|
|
||||||
: part.product_id;
|
|
||||||
|
|
||||||
if (!partsByProductId.has(productId)) {
|
|
||||||
partsByProductId.set(productId, []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
partsByProductId.get(productId).push(part);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = products.map((product) => ({
|
|
||||||
...product,
|
|
||||||
parts: partsByProductId.get(product.id) ?? [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/products-new", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const products = await database("mayo_products as product")
|
|
||||||
.select(
|
|
||||||
"product.id",
|
|
||||||
"product.note",
|
|
||||||
"model.name as model_name",
|
|
||||||
"model.strings_count as model_strings_count",
|
|
||||||
"model.scale as model_scale",
|
|
||||||
)
|
|
||||||
.leftJoin("mayo_models as model", "product.model_id", "model.id");
|
|
||||||
|
|
||||||
const productIds = products.map((product) => product.id);
|
|
||||||
|
|
||||||
const parts = productIds.length
|
|
||||||
? await database("mayo_parts as part")
|
|
||||||
.select(
|
|
||||||
"part.id",
|
|
||||||
"part.product_id",
|
|
||||||
"part.part_type",
|
|
||||||
"part.top_finish",
|
|
||||||
"part.back_finish",
|
|
||||||
"top_color.name as top_color_name",
|
|
||||||
"back_color.name as back_color_name",
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
"mayo_colors as top_color",
|
|
||||||
"part.top_color_id",
|
|
||||||
"top_color.id",
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
"mayo_colors as back_color",
|
|
||||||
"part.back_color_id",
|
|
||||||
"back_color.id",
|
|
||||||
)
|
|
||||||
.whereIn("part.product_id", productIds)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const partsByProductId = new Map();
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
if (!partsByProductId.has(part.product_id)) {
|
|
||||||
partsByProductId.set(part.product_id, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
partsByProductId.get(part.product_id).push({
|
|
||||||
id: part.id,
|
|
||||||
product_id: part.product_id,
|
|
||||||
part_type: part.part_type,
|
|
||||||
top_color_id: {
|
|
||||||
name: part.top_color_name,
|
|
||||||
},
|
|
||||||
back_color_id: {
|
|
||||||
name: part.back_color_name,
|
|
||||||
},
|
|
||||||
top_finish: part.top_finish,
|
|
||||||
back_finish: part.back_finish,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = products.map((product) => ({
|
|
||||||
id: product.id,
|
|
||||||
note: product.note,
|
|
||||||
model_id: {
|
|
||||||
name: product.model_name,
|
|
||||||
strings_count: product.model_strings_count,
|
|
||||||
scale: product.model_scale,
|
|
||||||
},
|
|
||||||
parts: partsByProductId.get(product.id) ?? [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/orders", async (req, res) => {
|
|
||||||
const itemsService = new ItemsService("mayo_order_products", {
|
|
||||||
schema: await getSchema(),
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const items = await itemsService.readByQuery({
|
|
||||||
fields: [
|
|
||||||
"id",
|
|
||||||
"product_order_index",
|
|
||||||
"order_id.order_number",
|
|
||||||
"order_id.order_year",
|
|
||||||
"order_id.client_id.name",
|
|
||||||
"product_id.id",
|
|
||||||
"product_id.note",
|
|
||||||
"product_id.model_id.name",
|
|
||||||
"product_id.model_id.strings_count",
|
|
||||||
"product_id.model_id.scale",
|
|
||||||
],
|
|
||||||
limit: -1,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(items);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/my-products", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const products = await database("mayo_products as mp")
|
|
||||||
.select(
|
|
||||||
"mp.id",
|
|
||||||
"mm.name as model",
|
|
||||||
"mo.order_number",
|
|
||||||
"mo.order_year",
|
|
||||||
"mop.product_order_index as order_index",
|
|
||||||
)
|
|
||||||
.leftJoin("mayo_order_products as mop", "mop.product_id", "mp.id")
|
|
||||||
.leftJoin("mayo_orders as mo", "mo.id", "mop.order_id")
|
|
||||||
.leftJoin("mayo_models as mm", "mm.id", "mp.model_id");
|
|
||||||
|
|
||||||
const productIds = products.map((product) => product.id);
|
|
||||||
|
|
||||||
const parts = productIds.length
|
|
||||||
? await database("mayo_parts as part")
|
|
||||||
.select("part.product_id", "part.part_type")
|
|
||||||
.whereIn("part.product_id", productIds)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const partTypesByProductId = new Map();
|
|
||||||
|
|
||||||
for (const part of parts) {
|
|
||||||
if (!partTypesByProductId.has(part.product_id)) {
|
|
||||||
partTypesByProductId.set(part.product_id, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.part_type) {
|
|
||||||
partTypesByProductId.get(part.product_id).push(part.part_type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = products.map((product) => ({
|
|
||||||
...product,
|
|
||||||
part_types: partTypesByProductId.get(product.id) ?? [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/my-products2", async (req, res) => {
|
|
||||||
const productsService = new ItemsService("mayo_products", {
|
|
||||||
schema: await getSchema(),
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
const partsService = new ItemsService("mayo_parts", {
|
|
||||||
schema: await getSchema(),
|
|
||||||
accountability: req.accountability,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const products = await productsService.readByQuery({
|
|
||||||
fields: [
|
|
||||||
"id",
|
|
||||||
"note",
|
|
||||||
"model_id.name",
|
|
||||||
"model_id.strings_count",
|
|
||||||
"model_id.scale",
|
|
||||||
],
|
|
||||||
limit: -1,
|
|
||||||
});
|
|
||||||
res.json(products);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export async function getDictionaries({ database }) {
|
||||||
|
const [models, clients, productionLists, operations, colors, finishes] = await Promise.all([
|
||||||
|
database('mayo_product_models').select('id', 'name').orderBy('name', 'asc'),
|
||||||
|
database('mayo_clients').select('id', 'name', 'country').orderBy('name', 'asc'),
|
||||||
|
database('mayo_production_lists').select('id', 'name').orderBy('name', 'asc'),
|
||||||
|
database('mayo_production_operations').select('id', 'code', 'name').orderBy('name', 'asc'),
|
||||||
|
database('mayo_colors').select('id', 'name').orderBy('name', 'asc'),
|
||||||
|
database('mayo_finishes').select('id', 'code', 'name').orderBy('name', 'asc'),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
models,
|
||||||
|
clients,
|
||||||
|
productionLists,
|
||||||
|
operations,
|
||||||
|
colors,
|
||||||
|
finishes,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { serializeProduct } from '../serializers/product.serializer.js'
|
||||||
|
import { createTimelinePreview } from '../serializers/timeline.serializer.js'
|
||||||
|
import { parseOrderSearch } from '../utils/order-search.js'
|
||||||
|
import { createPageInfo, parsePagination } from '../utils/pagination.js'
|
||||||
|
|
||||||
|
export async function getProducts({ database }, query = {}) {
|
||||||
|
const { limit, offset } = parsePagination(query)
|
||||||
|
const orderSearch = parseOrderSearch(query.orderSearch)
|
||||||
|
|
||||||
|
const baseQuery = createProductsBaseQuery(database, query, orderSearch)
|
||||||
|
|
||||||
|
const [{ total }] = await baseQuery
|
||||||
|
.clone()
|
||||||
|
.clearSelect()
|
||||||
|
.clearOrder()
|
||||||
|
.countDistinct({ total: 'order_item.id' })
|
||||||
|
|
||||||
|
const rows = await baseQuery
|
||||||
|
.clone()
|
||||||
|
.select(
|
||||||
|
'order_item.id as order_item_id',
|
||||||
|
'order_item.order_index',
|
||||||
|
'product.id as product_id',
|
||||||
|
'ord.order_number',
|
||||||
|
'ord.order_year',
|
||||||
|
'model.name as model_name',
|
||||||
|
'client.name as client_name',
|
||||||
|
)
|
||||||
|
.orderBy('ord.order_year', 'desc')
|
||||||
|
.orderBy('ord.order_number', 'desc')
|
||||||
|
.orderBy('order_item.order_index', 'asc')
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
|
||||||
|
const productIds = rows.map((row) => row.product_id)
|
||||||
|
const orderItemIds = rows.map((row) => row.order_item_id)
|
||||||
|
const [productionListsByOrderItemId, finishesByProductId, eventsByProductId] = await Promise.all([
|
||||||
|
readProductionListsByOrderItemId(database, orderItemIds),
|
||||||
|
readFinishesByProductId(database, productIds),
|
||||||
|
readEventsByProductId(database, productIds),
|
||||||
|
])
|
||||||
|
|
||||||
|
const items = rows.map((row) =>
|
||||||
|
serializeProduct(row, {
|
||||||
|
finish: serializeFinish(finishesByProductId.get(row.product_id)),
|
||||||
|
productionLists: productionListsByOrderItemId.get(row.order_item_id) ?? [],
|
||||||
|
timelinePreview: createTimelinePreview(eventsByProductId.get(row.product_id) ?? []),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
pageInfo: createPageInfo({
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
total: Number(total),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProductsBaseQuery(database, query, orderSearch) {
|
||||||
|
const productsQuery = database('mayo_order_items as order_item')
|
||||||
|
.join('mayo_products as product', 'order_item.product_id', 'product.id')
|
||||||
|
.join('mayo_orders as ord', 'order_item.order_id', 'ord.id')
|
||||||
|
.leftJoin('mayo_product_models as model', 'product.product_model_id', 'model.id')
|
||||||
|
.leftJoin('mayo_clients as client', 'ord.client_id', 'client.id')
|
||||||
|
|
||||||
|
applyFilters(productsQuery, query, orderSearch)
|
||||||
|
|
||||||
|
return productsQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters(productsQuery, query, orderSearch) {
|
||||||
|
if (query.model) {
|
||||||
|
productsQuery.where('product.product_model_id', query.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.client) {
|
||||||
|
productsQuery.where('ord.client_id', query.client)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.year) {
|
||||||
|
productsQuery.where('ord.order_year', Number(query.year))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.productionList) {
|
||||||
|
productsQuery.whereExists(function existsProductionList() {
|
||||||
|
this.select(1)
|
||||||
|
.from('mayo_product_production_lists as product_list')
|
||||||
|
.whereRaw('product_list.order_item_id = order_item.id')
|
||||||
|
.where('product_list.production_list_id', query.productionList)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.finish) {
|
||||||
|
productsQuery.whereExists(function existsFinish() {
|
||||||
|
this.select(1)
|
||||||
|
.from('mayo_product_parts as part')
|
||||||
|
.leftJoin('mayo_finishes as top_finish', 'part.top_finish_id', 'top_finish.id')
|
||||||
|
.leftJoin('mayo_finishes as back_finish', 'part.back_finish_id', 'back_finish.id')
|
||||||
|
.whereRaw('part.product_id = product.id')
|
||||||
|
.where((finishQuery) => {
|
||||||
|
finishQuery.where('top_finish.code', query.finish).orWhere('back_finish.code', query.finish)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
applyOrderSearchFilter(productsQuery, query.search, orderSearch)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOrderSearchFilter(productsQuery, search, orderSearch) {
|
||||||
|
if (!String(search ?? '').trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!orderSearch.length) {
|
||||||
|
productsQuery.whereRaw('1 = 0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
productsQuery.where((conditionsQuery) => {
|
||||||
|
for (const condition of orderSearch) {
|
||||||
|
conditionsQuery.orWhere((conditionQuery) => {
|
||||||
|
applyOrderSearchCondition(conditionQuery, condition)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOrderSearchCondition(conditionQuery, condition) {
|
||||||
|
if (condition.type === 'orderNumberPrefix' && condition.orderNumberPrefix) {
|
||||||
|
conditionQuery.where('ord.order_number', 'like', `${condition.orderNumberPrefix}%`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (condition.orderNumber !== null && condition.orderNumber !== undefined) {
|
||||||
|
conditionQuery.whereRaw('CAST("ord"."order_number" AS INTEGER) = ?', [condition.orderNumber])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (condition.orderYear !== null && condition.orderYear !== undefined) {
|
||||||
|
conditionQuery.where('ord.order_year', condition.orderYear)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (condition.orderIndex !== null && condition.orderIndex !== undefined) {
|
||||||
|
conditionQuery.where('order_item.order_index', condition.orderIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readProductionListsByOrderItemId(database, orderItemIds) {
|
||||||
|
const result = new Map()
|
||||||
|
|
||||||
|
if (!orderItemIds.length) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await database('mayo_product_production_lists as product_list')
|
||||||
|
.select('product_list.order_item_id', 'production_list.name')
|
||||||
|
.join(
|
||||||
|
'mayo_production_lists as production_list',
|
||||||
|
'product_list.production_list_id',
|
||||||
|
'production_list.id',
|
||||||
|
)
|
||||||
|
.whereIn('product_list.order_item_id', orderItemIds)
|
||||||
|
.orderBy('production_list.name', 'asc')
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!result.has(row.order_item_id)) {
|
||||||
|
result.set(row.order_item_id, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
result.get(row.order_item_id).push(row.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFinishesByProductId(database, productIds) {
|
||||||
|
const result = new Map()
|
||||||
|
|
||||||
|
if (!productIds.length) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await database('mayo_product_parts as part')
|
||||||
|
.select(
|
||||||
|
'part.product_id',
|
||||||
|
'top_finish.code as top_finish_code',
|
||||||
|
'back_finish.code as back_finish_code',
|
||||||
|
)
|
||||||
|
.leftJoin('mayo_finishes as top_finish', 'part.top_finish_id', 'top_finish.id')
|
||||||
|
.leftJoin('mayo_finishes as back_finish', 'part.back_finish_id', 'back_finish.id')
|
||||||
|
.whereIn('part.product_id', productIds)
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!result.has(row.product_id)) {
|
||||||
|
result.set(row.product_id, new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishes = result.get(row.product_id)
|
||||||
|
if (row.top_finish_code) finishes.add(row.top_finish_code)
|
||||||
|
if (row.back_finish_code) finishes.add(row.back_finish_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readEventsByProductId(database, productIds) {
|
||||||
|
const result = new Map()
|
||||||
|
|
||||||
|
if (!productIds.length) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await database('mayo_production_events as event')
|
||||||
|
.select(
|
||||||
|
'event.id',
|
||||||
|
'event.event_date as date',
|
||||||
|
'part.product_id',
|
||||||
|
'part_type.code as part_type',
|
||||||
|
'operation.id as operation_id',
|
||||||
|
'operation.code as operation_code',
|
||||||
|
'operation.name as operation_name',
|
||||||
|
'operation.description as operation_description',
|
||||||
|
)
|
||||||
|
.join('mayo_product_parts as part', 'event.product_part_id', 'part.id')
|
||||||
|
.join('mayo_part_types as part_type', 'part.part_type_id', 'part_type.id')
|
||||||
|
.leftJoin('mayo_production_operations as operation', 'event.operation_id', 'operation.id')
|
||||||
|
.whereIn('part.product_id', productIds)
|
||||||
|
.whereNotNull('event.operation_id')
|
||||||
|
.orderBy('event.event_date', 'asc')
|
||||||
|
.orderBy('event.ordinal', 'asc')
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!result.has(row.product_id)) {
|
||||||
|
result.set(row.product_id, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
result.get(row.product_id).push(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeFinish(finishes) {
|
||||||
|
if (!finishes?.size) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...finishes].sort().join('+')
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { getDictionaries } from '../repositories/dictionaries.repository.js'
|
||||||
|
import { serializeDictionaries } from '../serializers/dictionaries.serializer.js'
|
||||||
|
import { asyncHandler } from '../utils/async-handler.js'
|
||||||
|
|
||||||
|
export function registerGetDictionaries(router, context) {
|
||||||
|
router.get(
|
||||||
|
'/dictionaries',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const dictionaries = await getDictionaries(context)
|
||||||
|
res.json(serializeDictionaries(dictionaries))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { getProducts } from '../repositories/products.repository.js'
|
||||||
|
import { asyncHandler } from '../utils/async-handler.js'
|
||||||
|
|
||||||
|
export function registerGetProducts(router, context) {
|
||||||
|
router.get(
|
||||||
|
'/products',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const result = await getProducts(context, req.query)
|
||||||
|
res.json(result)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export function serializeDictionaries(dictionaries) {
|
||||||
|
return {
|
||||||
|
models: dictionaries.models.map(serializeModel),
|
||||||
|
clients: dictionaries.clients.map(serializeClient),
|
||||||
|
finishes: dictionaries.finishes.map(serializeFinish),
|
||||||
|
productionLists: dictionaries.productionLists.map(serializeProductionList),
|
||||||
|
operations: dictionaries.operations.map(serializeOperation),
|
||||||
|
colors: dictionaries.colors.map(serializeColor),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeModel(model) {
|
||||||
|
return {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeClient(client) {
|
||||||
|
return {
|
||||||
|
id: client.id,
|
||||||
|
name: client.name,
|
||||||
|
country: client.country,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeFinish(finish) {
|
||||||
|
return {
|
||||||
|
value: finish.code,
|
||||||
|
label: finish.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeProductionList(productionList) {
|
||||||
|
return {
|
||||||
|
id: productionList.id,
|
||||||
|
name: productionList.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeOperation(operation) {
|
||||||
|
return {
|
||||||
|
id: operation.id,
|
||||||
|
code: operation.code,
|
||||||
|
name: operation.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeColor(color) {
|
||||||
|
return {
|
||||||
|
id: color.id,
|
||||||
|
name: color.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export function serializeProduct(row, { finish, productionLists, timelinePreview }) {
|
||||||
|
const orderNumber = formatOrderNumber(row.order_number)
|
||||||
|
const orderIndex = Number(row.order_index)
|
||||||
|
const orderYear = Number(row.order_year)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.product_id,
|
||||||
|
orderId: `${orderNumber}/${orderYear}/${orderIndex}`,
|
||||||
|
orderNumber,
|
||||||
|
orderYear,
|
||||||
|
orderIndex,
|
||||||
|
model: row.model_name ?? null,
|
||||||
|
client: row.client_name ?? null,
|
||||||
|
finish,
|
||||||
|
productionLists,
|
||||||
|
timelinePreview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOrderNumber(value) {
|
||||||
|
return String(value ?? '').padStart(4, '0')
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export function createTimelinePreview(events) {
|
||||||
|
return {
|
||||||
|
body: createPartTimelinePreview(events, 'BODY'),
|
||||||
|
neck: createPartTimelinePreview(events, 'NECK'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPartTimelinePreview(events, partType) {
|
||||||
|
return events
|
||||||
|
.filter((event) => event.part_type === partType)
|
||||||
|
.map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
code: createOperationCode(event),
|
||||||
|
label: event.operation_name,
|
||||||
|
date: event.date,
|
||||||
|
status: 'done',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOperationCode(event) {
|
||||||
|
if (event.operation_code) {
|
||||||
|
return event.operation_code
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = event.operation_description || event.operation_name || ''
|
||||||
|
const firstWord = source.trim().split(/\s+/)[0] ?? ''
|
||||||
|
|
||||||
|
return firstWord.slice(0, 3).toUpperCase()
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export function asyncHandler(handler) {
|
||||||
|
return async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await handler(req, res, next)
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export function parseOrderSearch(value) {
|
||||||
|
if (!value) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value)
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const DEFAULT_LIMIT = 30
|
||||||
|
const MAX_LIMIT = 100
|
||||||
|
|
||||||
|
export function parsePagination(query = {}) {
|
||||||
|
const limit = clampNumber(query.limit, DEFAULT_LIMIT, 1, MAX_LIMIT)
|
||||||
|
const offset = clampNumber(query.offset, 0, 0, Number.MAX_SAFE_INTEGER)
|
||||||
|
|
||||||
|
return { limit, offset }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPageInfo({ limit, offset, total }) {
|
||||||
|
return {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore: offset + limit < total,
|
||||||
|
total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampNumber(value, fallback, min, max) {
|
||||||
|
const parsed = Number.parseInt(value, 10)
|
||||||
|
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(Math.max(parsed, min), max)
|
||||||
|
}
|
||||||
358
notes/directus-api.md
Normal file
358
notes/directus-api.md
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
# Directus Mayo API Extension
|
||||||
|
|
||||||
|
Data aktualizacji: 2026-05-20
|
||||||
|
|
||||||
|
## Cel
|
||||||
|
|
||||||
|
Ten dokument opisuje aktualny stan budowy custom API w Directus dla frontendu Duck Prod Manager.
|
||||||
|
|
||||||
|
Extension znajduje sie w:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
directus/extensions/directus-extension-mayo-api
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoint Directus extension ma miec identyfikator:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
mayo-api
|
||||||
|
```
|
||||||
|
|
||||||
|
Dlatego publiczne sciezki beda dostepne pod prefiksem:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/mayo-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aktualny stan
|
||||||
|
|
||||||
|
Katalog extension jest przygotowany i zawiera scaffold Directus endpoint extension.
|
||||||
|
|
||||||
|
Istniejace pliki:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
directus/extensions/directus-extension-mayo-api/README.md
|
||||||
|
directus/extensions/directus-extension-mayo-api/package.json
|
||||||
|
directus/extensions/directus-extension-mayo-api/src/index.js
|
||||||
|
directus/extensions/directus-extension-mayo-api/old/index.js.old
|
||||||
|
directus/extensions/directus-extension-mayo-api/old/old_index.js.old
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/index.js` rejestruje obecnie pierwsze dwa endpointy:
|
||||||
|
|
||||||
|
- `GET /mayo-api/products`
|
||||||
|
- `GET /mayo-api/dictionaries`
|
||||||
|
|
||||||
|
Stare pliki w `old/` sa tylko materialem referencyjnym. Nie traktujemy ich jako aktywnej implementacji.
|
||||||
|
|
||||||
|
## Zalozona architektura
|
||||||
|
|
||||||
|
Nie umieszczamy wszystkich endpointow w jednym pliku.
|
||||||
|
|
||||||
|
`src/index.js` ma pelnic role glownego routera:
|
||||||
|
|
||||||
|
- eksportuje Directus endpoint extension,
|
||||||
|
- ustawia `id: 'mayo-api'`,
|
||||||
|
- importuje pliki endpointow,
|
||||||
|
- rejestruje endpointy,
|
||||||
|
- nie zawiera zapytan SQL ani logiki biznesowej.
|
||||||
|
|
||||||
|
Kazdy endpoint ma miec osobny plik w katalogu `routes/`.
|
||||||
|
|
||||||
|
Logika wspolna powinna byc rozbita na:
|
||||||
|
|
||||||
|
- `repositories/` - zapytania do bazy Directus,
|
||||||
|
- `serializers/` - mapowanie danych z DB na format frontendu,
|
||||||
|
- `utils/` - drobne helpery techniczne.
|
||||||
|
|
||||||
|
## Proponowana struktura plikow
|
||||||
|
|
||||||
|
```txt
|
||||||
|
directus/extensions/directus-extension-mayo-api/src/
|
||||||
|
index.js
|
||||||
|
|
||||||
|
routes/
|
||||||
|
products.get.js
|
||||||
|
dictionaries.get.js
|
||||||
|
product-timeline.get.js # do zrobienia
|
||||||
|
product-timeline-event.post.js # do zrobienia
|
||||||
|
product-specification.get.js # do zrobienia
|
||||||
|
product-specification-refresh.post.js # do zrobienia
|
||||||
|
|
||||||
|
repositories/
|
||||||
|
products.repository.js
|
||||||
|
dictionaries.repository.js
|
||||||
|
timeline.repository.js # do zrobienia
|
||||||
|
specification.repository.js # do zrobienia
|
||||||
|
|
||||||
|
serializers/
|
||||||
|
product.serializer.js
|
||||||
|
timeline.serializer.js
|
||||||
|
specification.serializer.js # do zrobienia
|
||||||
|
dictionaries.serializer.js
|
||||||
|
|
||||||
|
utils/
|
||||||
|
async-handler.js
|
||||||
|
http-error.js # do zrobienia, jesli bedzie potrzebny
|
||||||
|
pagination.js
|
||||||
|
order-search.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpointy wymagane przez frontend
|
||||||
|
|
||||||
|
### 1. Lista produktow
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /mayo-api/products
|
||||||
|
```
|
||||||
|
|
||||||
|
Status: zaimplementowany w pierwszym etapie.
|
||||||
|
|
||||||
|
Parametry query:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
search,
|
||||||
|
orderSearch,
|
||||||
|
model,
|
||||||
|
client,
|
||||||
|
finish,
|
||||||
|
productionList,
|
||||||
|
year,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`orderSearch` przychodzi z frontendu jako JSON string.
|
||||||
|
|
||||||
|
Oczekiwana odpowiedz:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
items: [],
|
||||||
|
pageInfo: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore,
|
||||||
|
total,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Element `items` powinien miec ksztalt produktu uzywany przez frontend:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
orderId,
|
||||||
|
orderNumber,
|
||||||
|
orderYear,
|
||||||
|
orderIndex,
|
||||||
|
model,
|
||||||
|
client,
|
||||||
|
finish,
|
||||||
|
productionLists,
|
||||||
|
timelinePreview: {
|
||||||
|
body: [],
|
||||||
|
neck: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Slowniki
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /mayo-api/dictionaries
|
||||||
|
```
|
||||||
|
|
||||||
|
Status: zaimplementowany w pierwszym etapie.
|
||||||
|
|
||||||
|
Oczekiwana odpowiedz:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
models: [],
|
||||||
|
clients: [],
|
||||||
|
finishes: [],
|
||||||
|
productionLists: [],
|
||||||
|
operations: [],
|
||||||
|
colors: [],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Timeline produktu
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /mayo-api/products/:id/timeline
|
||||||
|
```
|
||||||
|
|
||||||
|
Status: do zrobienia.
|
||||||
|
|
||||||
|
Oczekiwana odpowiedz:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
productId,
|
||||||
|
events: [],
|
||||||
|
timelinePreview: {
|
||||||
|
body: [],
|
||||||
|
neck: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Dodanie eventu timeline
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /mayo-api/products/:id/timeline/events
|
||||||
|
```
|
||||||
|
|
||||||
|
Status: do zrobienia.
|
||||||
|
|
||||||
|
Oczekiwana odpowiedz:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
event: {},
|
||||||
|
timelinePreview: {
|
||||||
|
body: [],
|
||||||
|
neck: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Specyfikacja produktu
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /mayo-api/products/:id/specification
|
||||||
|
```
|
||||||
|
|
||||||
|
Status: do zrobienia.
|
||||||
|
|
||||||
|
Preferowana odpowiedz:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
productId,
|
||||||
|
orderId,
|
||||||
|
sourceUrl,
|
||||||
|
lastFetchedAt,
|
||||||
|
sections: [],
|
||||||
|
diff: [],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend akceptuje tez ksztalt:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
productId,
|
||||||
|
orderId,
|
||||||
|
sourceUrl,
|
||||||
|
lastFetchedAt,
|
||||||
|
specification: {
|
||||||
|
sections: [],
|
||||||
|
},
|
||||||
|
diff: [],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Odswiezenie specyfikacji produktu
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /mayo-api/products/:id/specification/refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
Status: do zrobienia.
|
||||||
|
|
||||||
|
Odpowiedz powinna miec taki sam ksztalt jak `GET /specification`, ale z aktualnym `lastFetchedAt` i ewentualnym `diff`.
|
||||||
|
|
||||||
|
## Kolejnosc implementacji
|
||||||
|
|
||||||
|
Rekomendowana kolejnosc:
|
||||||
|
|
||||||
|
1. `GET /mayo-api/products`
|
||||||
|
2. `GET /mayo-api/dictionaries`
|
||||||
|
3. `GET /mayo-api/products/:id/timeline`
|
||||||
|
4. `POST /mayo-api/products/:id/timeline/events`
|
||||||
|
5. `GET /mayo-api/products/:id/specification`
|
||||||
|
6. `POST /mayo-api/products/:id/specification/refresh`
|
||||||
|
|
||||||
|
Pierwsze dwa endpointy odblokowuja glowny ekran frontendu.
|
||||||
|
|
||||||
|
## Co zostalo zrobione
|
||||||
|
|
||||||
|
- Przeanalizowano `notes/api_services.md`.
|
||||||
|
- Przeanalizowano frontendowe implementacje HTTP w `frontend/src/services/*HttpApi.js`.
|
||||||
|
- Przeanalizowano store Pinia, zeby ustalic realne parametry i ksztalt danych.
|
||||||
|
- Ustalono liste 6 endpointow wymaganych przez obecny frontend.
|
||||||
|
- Ustalono, ze `index.js` bedzie tylko routerem/rejestratorem endpointow.
|
||||||
|
- Ustalono docelowa hierarchie plikow extension.
|
||||||
|
- Utworzono ten dokument jako aktualny opis stanu prac nad Directus extension.
|
||||||
|
- Utworzono katalogi `routes/`, `repositories/`, `serializers/`, `utils/`.
|
||||||
|
- Wypelniono `src/index.js` rejestracja pierwszych endpointow.
|
||||||
|
- Zaimplementowano `GET /mayo-api/products`.
|
||||||
|
- Zaimplementowano `GET /mayo-api/dictionaries`.
|
||||||
|
- Zweryfikowano nazwy kolekcji na podstawie `snapshot(6).json` i `db_schema.dbml`.
|
||||||
|
- Uruchomiono `npm run build` w katalogu extension. Build zakonczyl sie poprawnie.
|
||||||
|
- Dodano `README.md` dla extension.
|
||||||
|
- Dodano `license: "UNLICENSED"` w `package.json`, zeby extension bylo oznaczone jako pakiet wewnetrzny.
|
||||||
|
- Uruchomiono `npm run validate` w katalogu extension. Walidacja zakonczyla sie poprawnie.
|
||||||
|
- Dodano `mayo-api.http` z requestami REST Client do manualnego testowania endpointow.
|
||||||
|
- Przebudowano `db_schema.dbml` pod nowa baze Directus: poprawione nazwy, tabele slownikowe zamiast enumow.
|
||||||
|
- Dostosowano aktywne repository extension do nowego schematu.
|
||||||
|
- Uwzgledniono relacje `mayo_product_production_lists.order_item_id -> mayo_order_items.id`.
|
||||||
|
|
||||||
|
## Decyzje implementacyjne
|
||||||
|
|
||||||
|
- Aktywna implementacja ignoruje katalog `directus/extensions/directus-extension-mayo-api/old`.
|
||||||
|
- Pierwsza implementacja byla oparta o stary schemat Directus, ktory zawieral literowki:
|
||||||
|
- `mayo_order_porducts`
|
||||||
|
- `mayo_lisst_products`
|
||||||
|
- `mayo_color`
|
||||||
|
- `db_schema.dbml` zostal przebudowany jako propozycja nowego schematu:
|
||||||
|
- enumy zostaly zamienione na tabele slownikowe,
|
||||||
|
- literowki zostaly poprawione,
|
||||||
|
- nazwy kolekcji zostaly doprecyzowane pod Directus.
|
||||||
|
- Nowa baza Directus zostala utworzona zgodnie z `db_schema.dbml`, z jedna celowa zmiana:
|
||||||
|
- `mayo_product_production_lists.order_item_id` wskazuje na `mayo_order_items.id`.
|
||||||
|
- Extension zostal dostosowany do nowych nazw kolekcji i relacji `order_item_id`.
|
||||||
|
- `GET /products` buduje `timelinePreview` z `mayo_production_events`, `mayo_product_parts`, `mayo_part_types` i `mayo_production_operations`.
|
||||||
|
- `GET /products` filtruje po `model`, `client`, `year`, `productionList`, `finish` i `orderSearch`.
|
||||||
|
- `GET /dictionaries` pobiera finish z tabeli slownikowej `mayo_finishes`.
|
||||||
|
- Kod operacji pochodzi z `mayo_production_operations.code`.
|
||||||
|
- Uruchomiono `npm run build` po dostosowaniu do nowego schematu. Build zakonczyl sie poprawnie.
|
||||||
|
- Uruchomiono `npm run validate` po dostosowaniu do nowego schematu. Walidacja zakonczyla sie poprawnie.
|
||||||
|
|
||||||
|
## Proponowane nowe nazwy kolekcji
|
||||||
|
|
||||||
|
Stare nazwy i ich proponowane odpowiedniki:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
mayo_color -> mayo_colors
|
||||||
|
mayo_lisst_products -> mayo_product_production_lists
|
||||||
|
mayo_lists -> mayo_production_lists
|
||||||
|
mayo_models -> mayo_product_models
|
||||||
|
mayo_operations -> mayo_production_operations
|
||||||
|
mayo_order_porducts -> mayo_order_items
|
||||||
|
mayo_part_events -> mayo_production_events
|
||||||
|
mayo_parts -> mayo_product_parts
|
||||||
|
```
|
||||||
|
|
||||||
|
Enumy zostaly zastapione tabelami:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
mayo_models_neck_construction -> mayo_neck_constructions
|
||||||
|
mayo_parts_part_type -> mayo_part_types
|
||||||
|
mayo_parts_finish -> mayo_finishes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Do zrobienia
|
||||||
|
|
||||||
|
- Zaimplementowac `GET /mayo-api/products/:id/timeline`.
|
||||||
|
- Zaimplementowac `POST /mayo-api/products/:id/timeline/events`.
|
||||||
|
- Zaimplementowac `GET /mayo-api/products/:id/specification`.
|
||||||
|
- Zaimplementowac `POST /mayo-api/products/:id/specification/refresh`.
|
||||||
|
- Sprawdzic na realnych danych, czy `finish` na karcie produktu ma byc pojedynczym enumem, czy agregatem z `top_finish` i `back_finish`.
|
||||||
|
- Przetestowac frontend z `VITE_USE_MOCK_API=false`.
|
||||||
Reference in New Issue
Block a user