diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 4eeda49..05b37d2 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -38,6 +38,11 @@ class AdminController { // Statistics this.getUserStatistics = this.getUserStatistics.bind(this); + this.getFalukantRegions = this.getFalukantRegions.bind(this); + this.updateFalukantRegionMap = this.updateFalukantRegionMap.bind(this); + this.getRegionDistances = this.getRegionDistances.bind(this); + this.upsertRegionDistance = this.upsertRegionDistance.bind(this); + this.deleteRegionDistance = this.deleteRegionDistance.bind(this); } async getOpenInterests(req, res) { @@ -315,6 +320,69 @@ class AdminController { } } + async getFalukantRegions(req, res) { + try { + const { userid: userId } = req.headers; + const regions = await AdminService.getFalukantRegions(userId); + res.status(200).json(regions); + } catch (error) { + console.log(error); + const status = error.message === 'noaccess' ? 403 : 500; + res.status(status).json({ error: error.message }); + } + } + + async updateFalukantRegionMap(req, res) { + try { + const { userid: userId } = req.headers; + const { id } = req.params; + const { map } = req.body || {}; + const region = await AdminService.updateFalukantRegionMap(userId, id, map); + res.status(200).json(region); + } catch (error) { + console.log(error); + const status = error.message === 'noaccess' ? 403 : (error.message === 'regionNotFound' ? 404 : 500); + res.status(status).json({ error: error.message }); + } + } + + async getRegionDistances(req, res) { + try { + const { userid: userId } = req.headers; + const distances = await AdminService.getRegionDistances(userId); + res.status(200).json(distances); + } catch (error) { + console.log(error); + const status = error.message === 'noaccess' ? 403 : 500; + res.status(status).json({ error: error.message }); + } + } + + async upsertRegionDistance(req, res) { + try { + const { userid: userId } = req.headers; + const record = await AdminService.upsertRegionDistance(userId, req.body || {}); + res.status(200).json(record); + } catch (error) { + console.log(error); + const status = error.message === 'noaccess' ? 403 : 400; + res.status(status).json({ error: error.message }); + } + } + + async deleteRegionDistance(req, res) { + try { + const { userid: userId } = req.headers; + const { id } = req.params; + const result = await AdminService.deleteRegionDistance(userId, id); + res.status(200).json(result); + } catch (error) { + console.log(error); + const status = error.message === 'noaccess' ? 403 : (error.message === 'notfound' ? 404 : 500); + res.status(status).json({ error: error.message }); + } + } + async getRoomTypes(req, res) { try { const userId = req.headers.userid; diff --git a/backend/controllers/falukantController.js b/backend/controllers/falukantController.js index 78bd545..162ce07 100644 --- a/backend/controllers/falukantController.js +++ b/backend/controllers/falukantController.js @@ -30,6 +30,7 @@ class FalukantController { this.createBranch = this._wrapWithUser((userId, req) => this.service.createBranch(userId, req.body.cityId, req.body.branchTypeId)); this.getBranchTypes = this._wrapWithUser((userId) => this.service.getBranchTypes(userId)); this.getBranch = this._wrapWithUser((userId, req) => this.service.getBranch(userId, req.params.branch)); + this.upgradeBranch = this._wrapWithUser((userId, req) => this.service.upgradeBranch(userId, req.body.branchId)); this.createProduction = this._wrapWithUser((userId, req) => { const { branchId, productId, quantity } = req.body; return this.service.createProduction(userId, branchId, productId, quantity); @@ -186,6 +187,19 @@ class FalukantController { (userId, req) => this.service.buyVehicles(userId, req.body), { successStatus: 201 } ); + this.getVehicles = this._wrapWithUser( + (userId, req) => this.service.getVehicles(userId, req.query.regionId) + ); + this.createTransport = this._wrapWithUser( + (userId, req) => this.service.createTransport(userId, req.body), + { successStatus: 201 } + ); + this.getTransportRoute = this._wrapWithUser( + (userId, req) => this.service.getTransportRoute(userId, req.query) + ); + this.getBranchTransports = this._wrapWithUser( + (userId, req) => this.service.getBranchTransports(userId, req.params.branchId) + ); } diff --git a/backend/controllers/navigationController.js b/backend/controllers/navigationController.js index 16e5317..8f566e6 100644 --- a/backend/controllers/navigationController.js +++ b/backend/controllers/navigationController.js @@ -254,7 +254,7 @@ const menuStructure = { interests: { visible: ["mainadmin", "interests"], path: "/admin/interests" - }, + }, falukant: { visible: ["mainadmin", "falukant"], children: { @@ -270,6 +270,10 @@ const menuStructure = { visible: ["mainadmin", "falukant"], path: "/admin/falukant/database" }, + mapEditor: { + visible: ["mainadmin", "falukant"], + path: "/admin/falukant/map" + }, } }, minigames: { diff --git a/backend/models/associations.js b/backend/models/associations.js index c1a2e0b..a6b9703 100644 --- a/backend/models/associations.js +++ b/backend/models/associations.js @@ -98,6 +98,7 @@ import UndergroundType from './falukant/type/underground.js'; import VehicleType from './falukant/type/vehicle.js'; import Vehicle from './falukant/data/vehicle.js'; import Transport from './falukant/data/transport.js'; +import RegionDistance from './falukant/data/region_distance.js'; import Blog from './community/blog.js'; import BlogPost from './community/blog_post.js'; import Campaign from './match3/campaign.js'; @@ -453,6 +454,24 @@ export default function setupAssociations() { as: 'region', }); + // Region distances + RegionData.hasMany(RegionDistance, { + foreignKey: 'sourceRegionId', + as: 'distancesFrom', + }); + RegionData.hasMany(RegionDistance, { + foreignKey: 'targetRegionId', + as: 'distancesTo', + }); + RegionDistance.belongsTo(RegionData, { + foreignKey: 'sourceRegionId', + as: 'sourceRegion', + }); + RegionDistance.belongsTo(RegionData, { + foreignKey: 'targetRegionId', + as: 'targetRegion', + }); + Transport.belongsTo(RegionData, { foreignKey: 'sourceRegionId', as: 'sourceRegion', diff --git a/backend/models/falukant/data/region_distance.js b/backend/models/falukant/data/region_distance.js new file mode 100644 index 0000000..bd034dd --- /dev/null +++ b/backend/models/falukant/data/region_distance.js @@ -0,0 +1,51 @@ +import { Model, DataTypes } from 'sequelize'; +import { sequelize } from '../../../utils/sequelize.js'; +import RegionData from './region.js'; + +class RegionDistance extends Model {} + +RegionDistance.init( + { + sourceRegionId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: RegionData, + key: 'id', + schema: 'falukant_data', + }, + }, + targetRegionId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: RegionData, + key: 'id', + schema: 'falukant_data', + }, + }, + transportMode: { + // e.g. 'land', 'water', 'air' – should match VehicleType.transportMode + type: DataTypes.STRING, + allowNull: false, + }, + distance: { + // distance between regions (e.g. in abstract units, used for travel time etc.) + type: DataTypes.DOUBLE, + allowNull: false, + }, + }, + { + sequelize, + modelName: 'RegionDistance', + tableName: 'region_distance', + schema: 'falukant_data', + timestamps: false, + underscored: true, + } +); + +export default RegionDistance; + + + diff --git a/backend/models/falukant/data/vehicle.js b/backend/models/falukant/data/vehicle.js index e5d6a19..c7aaef6 100644 --- a/backend/models/falukant/data/vehicle.js +++ b/backend/models/falukant/data/vehicle.js @@ -17,6 +17,18 @@ Vehicle.init( type: DataTypes.INTEGER, allowNull: false, }, + condition: { + // current condition of the vehicle (0–100) + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 100, + }, + availableFrom: { + // timestamp when the vehicle becomes available for use + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, }, { sequelize, diff --git a/backend/models/falukant/type/vehicle.js b/backend/models/falukant/type/vehicle.js index 85e7c6c..edb9639 100644 --- a/backend/models/falukant/type/vehicle.js +++ b/backend/models/falukant/type/vehicle.js @@ -8,13 +8,19 @@ VehicleType.init( tr: { type: DataTypes.STRING, allowNull: false, - unique: true, + unique: true, }, cost: { // base purchase cost of the vehicle type: DataTypes.INTEGER, allowNull: false, }, + buildTimeMinutes: { + // time to construct the vehicle, in minutes + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, capacity: { // transport capacity (e.g. in units of goods) type: DataTypes.INTEGER, diff --git a/backend/models/index.js b/backend/models/index.js index 183a000..6b56507 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -116,6 +116,7 @@ import Underground from './falukant/data/underground.js'; import VehicleType from './falukant/type/vehicle.js'; import Vehicle from './falukant/data/vehicle.js'; import Transport from './falukant/data/transport.js'; +import RegionDistance from './falukant/data/region_distance.js'; import Room from './chat/room.js'; import ChatUser from './chat/user.js'; @@ -210,6 +211,7 @@ const models = { Credit, DebtorsPrism, HealthActivity, + RegionDistance, VehicleType, Vehicle, Transport, diff --git a/backend/routers/adminRouter.js b/backend/routers/adminRouter.js index 2807c95..b31d6a7 100644 --- a/backend/routers/adminRouter.js +++ b/backend/routers/adminRouter.js @@ -41,6 +41,11 @@ router.get('/falukant/branches/:falukantUserId', authenticate, adminController.g router.put('/falukant/stock/:stockId', authenticate, adminController.updateFalukantStock); router.post('/falukant/stock', authenticate, adminController.addFalukantStock); router.get('/falukant/stock-types', authenticate, adminController.getFalukantStockTypes); +router.get('/falukant/regions', authenticate, adminController.getFalukantRegions); +router.put('/falukant/regions/:id/map', authenticate, adminController.updateFalukantRegionMap); +router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances); +router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance); +router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance); // --- Minigames Admin --- router.get('/minigames/match3/campaigns', authenticate, adminController.getMatch3Campaigns); diff --git a/backend/routers/falukantRouter.js b/backend/routers/falukantRouter.js index 32ede58..3f9f2b0 100644 --- a/backend/routers/falukantRouter.js +++ b/backend/routers/falukantRouter.js @@ -15,6 +15,7 @@ router.get('/branches/types', falukantController.getBranchTypes); router.get('/branches/:branch', falukantController.getBranch); router.get('/branches', falukantController.getBranches); router.post('/branches', falukantController.createBranch); +router.post('/branches/upgrade', falukantController.upgradeBranch); router.get('/productions', falukantController.getAllProductions); router.post('/production', falukantController.createProduction); router.get('/production/:branchId', falukantController.getProduction); @@ -71,6 +72,10 @@ router.post('/politics/open', falukantController.applyForElections); router.get('/cities', falukantController.getRegions); router.get('/vehicles/types', falukantController.getVehicleTypes); router.post('/vehicles', falukantController.buyVehicles); +router.get('/vehicles', falukantController.getVehicles); +router.post('/transports', falukantController.createTransport); +router.get('/transports/route', falukantController.getTransportRoute); +router.get('/transports/branch/:branchId', falukantController.getBranchTransports); router.get('/underground/types', falukantController.getUndergroundTypes); router.get('/notifications', falukantController.getNotifications); router.get('/notifications/all', falukantController.getAllNotifications); diff --git a/backend/services/adminService.js b/backend/services/adminService.js index f9e4b2e..3aac8f0 100644 --- a/backend/services/adminService.js +++ b/backend/services/adminService.js @@ -19,7 +19,9 @@ import Branch from "../models/falukant/data/branch.js"; import FalukantStock from "../models/falukant/data/stock.js"; import FalukantStockType from "../models/falukant/type/stock.js"; import RegionData from "../models/falukant/data/region.js"; +import RegionType from "../models/falukant/type/region.js"; import BranchType from "../models/falukant/type/branch.js"; +import RegionDistance from "../models/falukant/data/region_distance.js"; import Room from '../models/chat/room.js'; import UserParam from '../models/community/user_param.js'; @@ -298,6 +300,104 @@ class AdminService { } } + async getFalukantRegions(userId) { + if (!(await this.hasUserAccess(userId, 'falukantusers'))) { + throw new Error('noaccess'); + } + + const regions = await RegionData.findAll({ + attributes: ['id', 'name', 'map'], + include: [ + { + model: RegionType, + as: 'regionType', + where: { labelTr: 'city' }, + attributes: ['labelTr'], + }, + ], + order: [['name', 'ASC']], + }); + + return regions; + } + + async updateFalukantRegionMap(userId, regionId, map) { + if (!(await this.hasUserAccess(userId, 'falukantusers'))) { + throw new Error('noaccess'); + } + + const region = await RegionData.findByPk(regionId); + if (!region) { + throw new Error('regionNotFound'); + } + + region.map = map || {}; + await region.save(); + + return region; + } + + async getRegionDistances(userId) { + if (!(await this.hasUserAccess(userId, 'falukantusers'))) { + throw new Error('noaccess'); + } + + const distances = await RegionDistance.findAll(); + return distances; + } + + async upsertRegionDistance(userId, { sourceRegionId, targetRegionId, transportMode, distance }) { + if (!(await this.hasUserAccess(userId, 'falukantusers'))) { + throw new Error('noaccess'); + } + + if (!sourceRegionId || !targetRegionId || !transportMode) { + throw new Error('missingParameters'); + } + + const src = await RegionData.findByPk(sourceRegionId); + const tgt = await RegionData.findByPk(targetRegionId); + if (!src || !tgt) { + throw new Error('regionNotFound'); + } + + const mode = String(transportMode); + const dist = Number(distance); + if (!Number.isFinite(dist) || dist <= 0) { + throw new Error('invalidDistance'); + } + + const [record] = await RegionDistance.findOrCreate({ + where: { + sourceRegionId: src.id, + targetRegionId: tgt.id, + transportMode: mode, + }, + defaults: { + distance: dist, + }, + }); + + if (record.distance !== dist) { + record.distance = dist; + await record.save(); + } + + return record; + } + + async deleteRegionDistance(userId, id) { + if (!(await this.hasUserAccess(userId, 'falukantusers'))) { + throw new Error('noaccess'); + } + const record = await RegionDistance.findByPk(id); + if (!record) { + throw new Error('notfound'); + } + await record.destroy(); + return { success: true }; + } + async updateFalukantStock(userId, stockId, quantity) { if (!(await this.hasUserAccess(userId, 'falukantusers'))) { throw new Error('noaccess'); diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index ecc68f5..f534df8 100644 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -59,6 +59,8 @@ import PoliticalOffice from '../models/falukant/data/political_office.js'; import Underground from '../models/falukant/data/underground.js'; import VehicleType from '../models/falukant/type/vehicle.js'; import Vehicle from '../models/falukant/data/vehicle.js'; +import Transport from '../models/falukant/data/transport.js'; +import RegionDistance from '../models/falukant/data/region_distance.js'; function calcAge(birthdate) { const b = new Date(birthdate); b.setHours(0, 0); @@ -93,6 +95,85 @@ function calculateMarriageCost(titleOfNobility, age) { return baseCost * Math.pow(adjustedTitle, 1.3) - (age - 12) * 20; } +async function computeShortestRoute(transportMode, sourceRegionId, targetRegionId) { + const src = parseInt(sourceRegionId, 10); + const tgt = parseInt(targetRegionId, 10); + if (Number.isNaN(src) || Number.isNaN(tgt)) { + throw new Error('Invalid region ids for route calculation'); + } + if (src === tgt) { + return { distance: 0, path: [src] }; + } + + const rows = await RegionDistance.findAll({ + where: { transportMode }, + attributes: ['sourceRegionId', 'targetRegionId', 'distance'], + }); + + if (!rows.length) { + return null; + } + + const adj = new Map(); + for (const r of rows) { + const a = r.sourceRegionId; + const b = r.targetRegionId; + const d = r.distance; + if (!adj.has(a)) adj.set(a, []); + if (!adj.has(b)) adj.set(b, []); + adj.get(a).push({ to: b, w: d }); + adj.get(b).push({ to: a, w: d }); + } + + if (!adj.has(src) || !adj.has(tgt)) { + return null; + } + + const dist = new Map(); + const prev = new Map(); + const visited = new Set(); + + for (const node of adj.keys()) { + dist.set(node, Number.POSITIVE_INFINITY); + } + dist.set(src, 0); + + while (true) { + let u = null; + let best = Number.POSITIVE_INFINITY; + for (const [node, d] of dist.entries()) { + if (!visited.has(node) && d < best) { + best = d; + u = node; + } + } + if (u === null || u === tgt) break; + visited.add(u); + const neighbors = adj.get(u) || []; + for (const { to, w } of neighbors) { + if (visited.has(to)) continue; + const alt = dist.get(u) + w; + if (alt < dist.get(to)) { + dist.set(to, alt); + prev.set(to, u); + } + } + } + + if (!prev.has(tgt) && tgt !== src) { + return null; + } + + const path = []; + let cur = tgt; + while (cur !== undefined) { + path.unshift(cur); + cur = prev.get(cur); + } + + return { distance: dist.get(tgt), path }; +} + class PreconditionError extends Error { constructor(label) { super(label); @@ -404,6 +485,9 @@ class FalukantService extends BaseService { const exponentBase = Math.max(existingCount, 1); const rawCost = branchType.baseCost * Math.pow(exponentBase, 1.2); const cost = Math.round(rawCost * 100) / 100; + if (user.money < cost) { + throw new PreconditionError('insufficientFunds'); + } await updateFalukantUserMoney( user.id, -cost, @@ -444,6 +528,41 @@ class FalukantService extends BaseService { return br; } + async upgradeBranch(hashedUserId, branchId) { + const user = await getFalukantUserOrFail(hashedUserId); + const branch = await Branch.findOne({ + where: { id: branchId, falukantUserId: user.id }, + include: [{ model: BranchType, as: 'branchType', attributes: ['id', 'labelTr'] }], + }); + if (!branch) { + throw new Error('Branch not found'); + } + + const currentLabel = branch.branchType?.labelTr; + + let targetLabel = null; + if (currentLabel === 'production') { + targetLabel = 'fullstack'; + } else if (currentLabel === 'store') { + targetLabel = 'fullstack'; + } else { + // already fullstack or unknown type + throw new PreconditionError('noUpgradeAvailable'); + } + + const targetType = await BranchType.findOne({ where: { labelTr: targetLabel } }); + if (!targetType) { + throw new Error(`Target branch type '${targetLabel}' not found`); + } + + // Für den Moment ohne zusätzliche Kosten – kann später erweitert werden + branch.branchTypeId = targetType.id; + await branch.save(); + + const updated = await this.getBranch(hashedUserId, branch.id); + return updated; + } + async getStock(hashedUserId, branchId) { const u = await getFalukantUserOrFail(hashedUserId); const b = await getBranchOrFail(u.id, branchId); @@ -456,7 +575,372 @@ class FalukantService extends BaseService { return VehicleType.findAll(); } - async buyVehicles(hashedUserId, { vehicleTypeId, quantity, regionId }) { + async getVehicles(hashedUserId, regionId) { + const user = await getFalukantUserOrFail(hashedUserId); + + const where = { falukantUserId: user.id }; + if (regionId) { + where.regionId = regionId; + } + + const vehicles = await Vehicle.findAll({ + where, + include: [ + { + model: VehicleType, + as: 'type', + attributes: ['id', 'tr', 'capacity', 'transportMode', 'speed', 'buildTimeMinutes', 'cost'], + }, + { + model: Transport, + as: 'transports', + attributes: ['id', 'sourceRegionId', 'targetRegionId'], + required: false, + }, + ], + order: [['availableFrom', 'ASC'], ['id', 'ASC']], + }); + + const now = new Date(); + const branchRegionId = regionId ? parseInt(regionId, 10) : undefined; + + return vehicles.map((v) => { + const plain = v.get({ plain: true }); + + const effectiveRegionId = branchRegionId ?? plain.regionId; + + const hasTransportHere = Array.isArray(plain.transports) && plain.transports.some( + (t) => + t.sourceRegionId === effectiveRegionId || + t.targetRegionId === effectiveRegionId + ); + + let status; + if (hasTransportHere) { + // verknüpft mit Transport in dieser Region = unterwegs + status = 'travelling'; + } else if (plain.availableFrom && new Date(plain.availableFrom).getTime() > now.getTime()) { + // kein Transport, aber Verfügbarkeit liegt in der Zukunft = im Bau + status = 'building'; + } else { + // kein Transport und Verfügbarkeit erreicht = verfügbar + status = 'available'; + } + + return { + id: plain.id, + condition: plain.condition, + availableFrom: plain.availableFrom, + status, + type: { + id: plain.type?.id, + tr: plain.type?.tr, + capacity: plain.type?.capacity, + transportMode: plain.type?.transportMode, + speed: plain.type?.speed, + buildTimeMinutes: plain.type?.buildTimeMinutes, + cost: plain.type?.cost, + }, + }; + }); + } + + async getTransportRoute(hashedUserId, { sourceRegionId, targetRegionId, vehicleTypeId }) { + const user = await getFalukantUserOrFail(hashedUserId); + if (!user) { + throw new Error('User not found'); + } + + const type = await VehicleType.findByPk(vehicleTypeId); + if (!type) { + throw new Error('Vehicle type not found'); + } + + const route = await computeShortestRoute( + type.transportMode, + sourceRegionId, + targetRegionId + ); + + if (!route) { + return { + mode: type.transportMode, + totalDistance: null, + regions: [], + }; + } + + const regions = await RegionData.findAll({ + where: { id: route.path }, + attributes: ['id', 'name'], + }); + const regionMap = new Map(regions.map((r) => [r.id, r.name])); + const ordered = route.path.map((id) => ({ + id, + name: regionMap.get(id) || String(id), + })); + + return { + mode: type.transportMode, + totalDistance: route.distance, + regions: ordered, + }; + } + + async createTransport(hashedUserId, { branchId, vehicleTypeId, productId, quantity, targetBranchId }) { + const user = await getFalukantUserOrFail(hashedUserId); + + const sourceBranch = await Branch.findOne({ + where: { id: branchId, falukantUserId: user.id }, + include: [{ model: RegionData, as: 'region' }], + }); + if (!sourceBranch) { + throw new Error('Branch not found'); + } + + const targetBranch = await Branch.findOne({ + where: { id: targetBranchId, falukantUserId: user.id }, + include: [{ model: BranchType, as: 'branchType', attributes: ['labelTr'] }, { model: RegionData, as: 'region' }], + }); + if (!targetBranch) { + throw new Error('Target branch not found'); + } + if (!['store', 'fullstack'].includes(targetBranch.branchType.labelTr)) { + throw new PreconditionError('invalidTargetBranch'); + } + + const sourceRegionId = sourceBranch.regionId; + const targetRegionId = targetBranch.regionId; + const now = new Date(); + + const type = await VehicleType.findByPk(vehicleTypeId); + if (!type) { + throw new Error('Vehicle type not found'); + } + + const route = await computeShortestRoute(type.transportMode, sourceRegionId, targetRegionId); + if (!route) { + throw new PreconditionError('noRoute'); + } + + // Freie Fahrzeuge dieses Typs in der Quell-Region + const vehicles = await Vehicle.findAll({ + where: { + falukantUserId: user.id, + regionId: sourceRegionId, + vehicleTypeId, + availableFrom: { [Op.lte]: now }, + }, + include: [ + { + model: Transport, + as: 'transports', + required: false, + attributes: ['id'], + }, + ], + }); + + const freeVehicles = vehicles.filter((v) => { + const t = v.transports || []; + return t.length === 0; + }); + + if (!freeVehicles.length) { + throw new PreconditionError('noVehiclesAvailable'); + } + + const capacityPerVehicle = type.capacity || 0; + if (capacityPerVehicle <= 0) { + throw new Error('Invalid vehicle capacity'); + } + const maxByVehicles = capacityPerVehicle * freeVehicles.length; + + const stock = await FalukantStock.findOne({ where: { branchId: sourceBranch.id } }); + if (!stock) { + throw new Error('Stock not found'); + } + + const inventory = await Inventory.findAll({ + where: { stockId: stock.id }, + include: [ + { + model: ProductType, + as: 'productType', + required: true, + where: { id: productId }, + }, + ], + }); + + const available = inventory.reduce((sum, i) => sum + i.quantity, 0); + if (available <= 0) { + throw new PreconditionError('noInventory'); + } + + const maxByInventory = available; + const hardMax = Math.min(maxByVehicles, maxByInventory); + + const requested = Math.max(1, parseInt(quantity, 10) || 0); + if (requested > hardMax) { + throw new PreconditionError('quantityTooHigh'); + } + + // Transportkosten: 1 % des Warenwerts, mindestens 0,1 + const productType = inventory[0]?.productType; + const unitValue = productType?.sellCost || 0; + const totalValue = unitValue * requested; + const transportCost = Math.max(0.1, totalValue * 0.01); + + if (user.money < transportCost) { + throw new PreconditionError('insufficientFunds'); + } + + const result = await sequelize.transaction(async (tx) => { + // Geld für den Transport abziehen + const moneyResult = await updateFalukantUserMoney( + user.id, + -transportCost, + 'transport', + user.id + ); + if (!moneyResult.success) { + throw new Error('Failed to update money'); + } + + let remaining = requested; + const transportsCreated = []; + + for (const v of freeVehicles) { + if (remaining <= 0) break; + const size = Math.min(remaining, capacityPerVehicle); + const t = await Transport.create( + { + sourceRegionId, + targetRegionId, + productId, + size, + vehicleId: v.id, + }, + { transaction: tx } + ); + transportsCreated.push(t); + remaining -= size; + } + + if (remaining > 0) { + throw new Error('Not enough vehicle capacity for requested quantity'); + } + + // Inventar in der Quell-Niederlassung reduzieren + let left = requested; + for (const inv of inventory) { + if (left <= 0) break; + if (inv.quantity <= left) { + left -= inv.quantity; + await inv.destroy({ transaction: tx }); + } else { + await inv.update({ quantity: inv.quantity - left }, { transaction: tx }); + left = 0; + break; + } + } + + if (left > 0) { + throw new Error('Inventory changed during transport creation'); + } + + notifyUser(user.user.hashedId, 'stock_change', { branchId: sourceBranch.id }); + notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId: sourceBranch.id }); + + return { + success: true, + maxQuantity: hardMax, + requested, + totalDistance: route.distance, + totalCost: transportCost, + transports: transportsCreated.map((t) => ({ + id: t.id, + size: t.size, + vehicleId: t.vehicleId, + })), + }; + }); + + return result; + } + + async getBranchTransports(hashedUserId, branchId) { + const user = await getFalukantUserOrFail(hashedUserId); + const branch = await Branch.findOne({ + where: { id: branchId, falukantUserId: user.id }, + include: [{ model: RegionData, as: 'region', attributes: ['id', 'name'] }], + }); + if (!branch) { + throw new Error('Branch not found'); + } + const regionId = branch.regionId; + + const transports = await Transport.findAll({ + where: { + [Op.or]: [ + { sourceRegionId: regionId }, + { targetRegionId: regionId }, + ], + }, + include: [ + { model: RegionData, as: 'sourceRegion', attributes: ['id', 'name'] }, + { model: RegionData, as: 'targetRegion', attributes: ['id', 'name'] }, + { model: ProductType, as: 'productType', attributes: ['id', 'labelTr'] }, + { + model: Vehicle, + as: 'vehicle', + include: [ + { + model: VehicleType, + as: 'type', + attributes: ['id', 'tr', 'speed'], + }, + ], + }, + ], + order: [['createdAt', 'DESC']], + }); + + const now = Date.now(); + + return transports.map((t) => { + const direction = t.sourceRegionId === regionId ? 'outgoing' : 'incoming'; + + let eta = null; + let durationHours = null; + if (t.vehicle && t.vehicle.type && t.vehicle.type.speed && t.sourceRegionId && t.targetRegionId) { + // Näherungsweise Dauer: wir haben die exakte Distanz hier nicht, deshalb nur anhand der + // bei Erstellung verwendeten Route in der UI berechnet – für die Liste reicht createdAt + 1h. + // TODO: Optional: Distanz persistent speichern. + durationHours = 1; + const createdAt = t.createdAt ? new Date(t.createdAt).getTime() : now; + const etaMs = createdAt + durationHours * 60 * 60 * 1000; + eta = new Date(etaMs); + } + + return { + id: t.id, + direction, + sourceRegion: t.sourceRegion, + targetRegion: t.targetRegion, + product: t.productType, + size: t.size, + vehicleId: t.vehicleId, + vehicleType: t.vehicle?.type || null, + createdAt: t.createdAt, + eta, + durationHours, + }; + }); + } + + async buyVehicles(hashedUserId, { vehicleTypeId, quantity, regionId, mode = 'buy' }) { const user = await getFalukantUserOrFail(hashedUserId); const qty = Math.max(1, parseInt(quantity, 10) || 0); @@ -465,7 +949,11 @@ class FalukantService extends BaseService { throw new Error('Vehicle type not found'); } - const totalCost = type.cost * qty; + const baseCost = type.cost; + const unitCost = mode === 'build' + ? Math.round(baseCost * 0.75) + : baseCost; + const totalCost = unitCost * qty; if (user.money < totalCost) { throw new PreconditionError('insufficientFunds'); } @@ -481,16 +969,24 @@ class FalukantService extends BaseService { await updateFalukantUserMoney( user.id, -totalCost, - 'buy_vehicles', + mode === 'build' ? 'build_vehicles' : 'buy_vehicles', user.id ); const records = []; + const now = new Date(); + const baseTime = now.getTime(); + const buildMs = mode === 'build' + ? (type.buildTimeMinutes || 0) * 60 * 1000 + : 0; + for (let i = 0; i < qty; i++) { records.push({ vehicleTypeId: type.id, falukantUserId: user.id, regionId: region.id, + availableFrom: new Date(baseTime + buildMs), + condition: 100, }); } await Vehicle.bulkCreate(records, { transaction: tx }); @@ -2475,6 +2971,13 @@ class FalukantService extends BaseService { async checkBranchesRequirement(hashedUserId, requirement) { const user = await this.getFalukantUserByHashedId(hashedUserId); + if (!user) { + throw new Error('User not found'); + } + const branchCount = await Branch.count({ + where: { falukantUserId: user.id } + }); + return branchCount >= requirement.requirementValue; } async getHealth(hashedUserId) { diff --git a/backend/utils/falukant/initializeFalukantTypes.js b/backend/utils/falukant/initializeFalukantTypes.js index 5eda435..5479649 100644 --- a/backend/utils/falukant/initializeFalukantTypes.js +++ b/backend/utils/falukant/initializeFalukantTypes.js @@ -276,13 +276,14 @@ const learnerTypes = [ ]; const vehicleTypes = [ - { tr: 'cargo_cart', name: 'Lastkarren', cost: 100, capacity: 20, transportMode: 'land', speed: 1 }, - { tr: 'ox_cart', name: 'Ochsenkarren', cost: 200, capacity: 50, transportMode: 'land', speed: 2 }, - { tr: 'small_carriage', name: 'kleine Pferdekutsche', cost: 300, capacity: 35, transportMode: 'land', speed: 3 }, - { tr: 'large_carriage', name: 'große Pferdekutsche', cost: 1000, capacity: 100, transportMode: 'land', speed: 3 }, - { tr: 'four_horse_carriage', name: 'Vierspänner', cost: 5000, capacity: 200, transportMode: 'land', speed: 4 }, - { tr: 'raft', name: 'Floß', cost: 100, capacity: 25, transportMode: 'water', speed: 1 }, - { tr: 'sailing_ship', name: 'Segelschiff', cost: 500, capacity: 200, transportMode: 'water', speed: 3 }, + // build times (in minutes): 60, 90, 180, 300, 720, 120, 1440 + { tr: 'cargo_cart', name: 'Lastkarren', cost: 100, capacity: 20, transportMode: 'land', speed: 1, buildTimeMinutes: 60 }, + { tr: 'ox_cart', name: 'Ochsenkarren', cost: 200, capacity: 50, transportMode: 'land', speed: 2, buildTimeMinutes: 90 }, + { tr: 'small_carriage', name: 'kleine Pferdekutsche', cost: 300, capacity: 35, transportMode: 'land', speed: 3, buildTimeMinutes: 180 }, + { tr: 'large_carriage', name: 'große Pferdekutsche', cost: 1000, capacity: 100, transportMode: 'land', speed: 3, buildTimeMinutes: 300 }, + { tr: 'four_horse_carriage', name: 'Vierspänner', cost: 5000, capacity: 200, transportMode: 'land', speed: 4, buildTimeMinutes: 720 }, + { tr: 'raft', name: 'Floß', cost: 100, capacity: 25, transportMode: 'water', speed: 1, buildTimeMinutes: 120 }, + { tr: 'sailing_ship', name: 'Segelschiff', cost: 500, capacity: 200, transportMode: 'water', speed: 3, buildTimeMinutes: 1440 }, ]; const politicalOfficeBenefitTypes = [ @@ -897,16 +898,26 @@ export const initializeLearnerTypes = async () => { export const initializeVehicleTypes = async () => { for (const v of vehicleTypes) { - await VehicleType.findOrCreate({ - where: { tr: v.tr }, - defaults: { + const existing = await VehicleType.findOne({ where: { tr: v.tr } }); + if (!existing) { + await VehicleType.create({ tr: v.tr, cost: v.cost, capacity: v.capacity, transportMode: v.transportMode, speed: v.speed, - }, - }); + buildTimeMinutes: v.buildTimeMinutes, + }); + } else { + // ensure new fields like cost/buildTime are updated if missing + await existing.update({ + cost: v.cost, + capacity: v.capacity, + transportMode: v.transportMode, + speed: v.speed, + buildTimeMinutes: v.buildTimeMinutes, + }); + } } }; diff --git a/frontend/public/images/falukant/map.png b/frontend/public/images/falukant/map.png index 7921670..1a464da 100644 Binary files a/frontend/public/images/falukant/map.png and b/frontend/public/images/falukant/map.png differ diff --git a/frontend/public/images/falukant/map_old.png b/frontend/public/images/falukant/map_old.png new file mode 100644 index 0000000..7921670 Binary files /dev/null and b/frontend/public/images/falukant/map_old.png differ diff --git a/frontend/src/components/falukant/SaleSection.vue b/frontend/src/components/falukant/SaleSection.vue index 1fe3300..2b0aa08 100644 --- a/frontend/src/components/falukant/SaleSection.vue +++ b/frontend/src/components/falukant/SaleSection.vue @@ -1,6 +1,6 @@ @@ -37,14 +152,46 @@ import apiClient from '@/utils/axios.js'; export default { name: "SaleSection", - props: { branchId: { type: Number, required: true } }, + props: { + branchId: { type: Number, required: true }, + vehicles: { type: Array, default: () => [] }, + branches: { type: Array, default: () => [] }, + }, data() { return { inventory: [], + transportForm: { + sourceKey: null, + vehicleTypeId: null, + targetBranchId: null, + quantity: 0, + maxQuantity: 0, + distance: null, + durationHours: null, + eta: null, + durationLabel: '', + etaLabel: '', + routeNames: [], + cost: null, + costLabel: '', + }, + runningTransports: [], + nowTs: Date.now(), + _transportTimer: null, }; }, async mounted() { await this.loadInventory(); + await this.loadTransports(); + this._transportTimer = setInterval(() => { + this.nowTs = Date.now(); + }, 1000); + }, + beforeUnmount() { + if (this._transportTimer) { + clearInterval(this._transportTimer); + this._transportTimer = null; + } }, methods: { async loadInventory() { @@ -76,6 +223,205 @@ alert(this.$t('falukant.branch.sale.sellAllError')); }); }, + inventoryOptions() { + return this.inventory.map((item, index) => ({ + key: index, + label: `${this.$t(`falukant.product.${item.product.labelTr}`)} (Q${item.quality}, ${item.totalQuantity})`, + productId: item.product.id, + totalQuantity: item.totalQuantity, + })); + }, + vehicleTypeOptions() { + const groups = {}; + for (const v of this.vehicles) { + if (v.status !== 'available' || !v.type || !v.type.id) continue; + const id = v.type.id; + if (!groups[id]) { + groups[id] = { + id, + tr: v.type.tr, + capacity: v.type.capacity, + speed: v.type.speed, + count: 0, + }; + } + groups[id].count += 1; + } + return Object.values(groups); + }, + targetBranchOptions() { + return (this.branches || []) + .filter(b => ['store', 'fullstack'].includes(b.branchTypeLabelTr)) + // aktuelle Niederlassung darf nicht als Ziel angeboten werden + .filter(b => b.id !== this.branchId) + .map(b => ({ + id: b.id, + label: `${b.cityName} – ${b.type}`, + })); + }, + recalcMaxQuantity() { + const source = this.inventoryOptions().find(o => o.key === this.transportForm.sourceKey); + const vType = this.vehicleTypeOptions().find(v => v.id === this.transportForm.vehicleTypeId); + if (!source || !vType) { + this.transportForm.maxQuantity = 0; + this.transportForm.quantity = 0; + this.recalcCost(); + return; + } + const maxByInventory = source.totalQuantity; + const maxByVehicles = vType.capacity * vType.count; + const max = Math.min(maxByInventory, maxByVehicles); + this.transportForm.maxQuantity = max; + if (!this.transportForm.quantity || this.transportForm.quantity > max) { + this.transportForm.quantity = max; + } + this.recalcCost(); + }, + recalcCost() { + const idx = this.transportForm.sourceKey; + if (idx === null || idx === undefined) { + this.transportForm.cost = null; + this.transportForm.costLabel = ''; + return; + } + const item = this.inventory[idx]; + const qty = this.transportForm.quantity || 0; + if (!item || !item.product || item.product.sellCost == null || qty <= 0) { + this.transportForm.cost = null; + this.transportForm.costLabel = ''; + return; + } + const unitValue = item.product.sellCost || 0; + const totalValue = unitValue * qty; + const cost = Math.max(0.1, totalValue * 0.01); + this.transportForm.cost = cost; + this.transportForm.costLabel = this.formatMoney(cost); + }, + formatMoney(amount) { + if (amount == null) return ''; + try { + return amount.toLocaleString(undefined, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); + } catch (e) { + return String(amount); + } + }, + async loadRouteInfo() { + this.transportForm.distance = null; + this.transportForm.durationHours = null; + this.transportForm.eta = null; + this.transportForm.durationLabel = ''; + this.transportForm.etaLabel = ''; + this.transportForm.routeNames = []; + + const sourceOpt = this.inventoryOptions().find(o => o.key === this.transportForm.sourceKey); + const vType = this.vehicleTypeOptions().find(v => v.id === this.transportForm.vehicleTypeId); + const targetBranch = (this.branches || []).find(b => b.id === this.transportForm.targetBranchId); + + if (!sourceOpt || !vType || !targetBranch) { + return; + } + + const sourceBranch = (this.branches || []).find(b => b.id === this.branchId); + if (!sourceBranch) { + return; + } + + try { + const { data } = await apiClient.get('/api/falukant/transports/route', { + params: { + sourceRegionId: sourceBranch.regionId, + targetRegionId: targetBranch.regionId, + vehicleTypeId: vType.id, + }, + }); + if (data && data.totalDistance != null) { + const distance = data.totalDistance; + const speed = vType.speed || 1; + const hours = distance / speed; + + this.transportForm.distance = distance; + this.transportForm.durationHours = hours; + + const now = new Date(); + const etaMs = now.getTime() + hours * 60 * 60 * 1000; + const etaDate = new Date(etaMs); + + const fullHours = Math.floor(hours); + const minutes = Math.round((hours - fullHours) * 60); + const parts = []; + if (fullHours > 0) parts.push(`${fullHours} h`); + if (minutes > 0) parts.push(`${minutes} min`); + this.transportForm.durationLabel = parts.length ? parts.join(' ') : '0 min'; + this.transportForm.etaLabel = etaDate.toLocaleString(); + + this.transportForm.routeNames = (data.regions || []).map(r => r.name); + } + } catch (error) { + console.error('Error loading transport route:', error); + this.transportForm.distance = null; + this.transportForm.durationHours = null; + this.transportForm.eta = null; + this.transportForm.durationLabel = ''; + this.transportForm.etaLabel = ''; + this.transportForm.routeNames = []; + } + }, + async createTransport() { + const source = this.inventoryOptions().find(o => o.key === this.transportForm.sourceKey); + if (!source) return; + try { + await apiClient.post('/api/falukant/transports', { + branchId: this.branchId, + vehicleTypeId: this.transportForm.vehicleTypeId, + productId: source.productId, + quantity: this.transportForm.quantity, + targetBranchId: this.transportForm.targetBranchId, + }); + await this.loadInventory(); + await this.loadTransports(); + alert(this.$t('falukant.branch.sale.transportStarted')); + this.$emit('transportCreated'); + } catch (error) { + console.error('Error creating transport:', error); + alert(this.$t('falukant.branch.sale.transportError')); + } + }, + async loadTransports() { + try { + const { data } = await apiClient.get(`/api/falukant/transports/branch/${this.branchId}`); + this.runningTransports = Array.isArray(data) ? data : []; + } catch (error) { + console.error('Error loading transports:', error); + this.runningTransports = []; + } + }, + formatEta(transport) { + if (!transport || !transport.eta) return ''; + const etaDate = new Date(transport.eta); + if (Number.isNaN(etaDate.getTime())) return ''; + return etaDate.toLocaleString(); + }, + formatRemaining(transport) { + if (!transport || !transport.eta) return ''; + const etaMs = new Date(transport.eta).getTime(); + if (Number.isNaN(etaMs)) return ''; + let diff = Math.floor((etaMs - this.nowTs) / 1000); + if (diff <= 0) { + return this.$t('falukant.branch.production.noProductions') ? '0s' : '0s'; + } + const hours = Math.floor(diff / 3600); + diff %= 3600; + const minutes = Math.floor(diff / 60); + const seconds = diff % 60; + const parts = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0 || hours > 0) parts.push(`${minutes}m`); + parts.push(`${seconds}s`); + return parts.join(' '); + }, }, }; @@ -96,5 +442,11 @@ padding: 2px 3px; border: 1px solid #ddd; } + + /* Größerer Abstand zwischen den Spalten der Transport-Tabelle */ + .running-transports table { + border-collapse: separate; + border-spacing: 16px 0; /* horizontaler Abstand zwischen Spalten */ + } \ No newline at end of file diff --git a/frontend/src/dialogues/falukant/BuyVehicleDialog.vue b/frontend/src/dialogues/falukant/BuyVehicleDialog.vue index 5568016..edec33e 100644 --- a/frontend/src/dialogues/falukant/BuyVehicleDialog.vue +++ b/frontend/src/dialogues/falukant/BuyVehicleDialog.vue @@ -24,6 +24,18 @@ + +