Implement tax handling for branches by adding tax percent to regions, updating product sell costs, and enhancing UI for tax summaries in BranchView
This commit is contained in:
23
backend/README_TAX.md
Normal file
23
backend/README_TAX.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Falukant Tax Migration & Configuration
|
||||||
|
|
||||||
|
This project now supports a per-region sales tax (`tax_percent`) for Falukant.
|
||||||
|
|
||||||
|
Migration
|
||||||
|
- A SQL migration was added: `backend/migrations/20260101000000-add-tax-percent-to-region.cjs`.
|
||||||
|
- It adds `tax_percent` numeric NOT NULL DEFAULT 7 to `falukant_data.region`.
|
||||||
|
|
||||||
|
Runtime configuration
|
||||||
|
- If you want taxes to be forwarded to a treasury account, set environment variable `TREASURY_FALUKANT_USER_ID` to a valid `falukant_user.id`.
|
||||||
|
- If `TREASURY_FALUKANT_USER_ID` is not set, taxes will be calculated and currently not forwarded to any account.
|
||||||
|
|
||||||
|
Implementation notes
|
||||||
|
- Backend service `sellProduct` and `sellAllProducts` now compute tax per-region and credit net to seller and tax to treasury (if configured).
|
||||||
|
- Tax arithmetic uses rounding to 2 decimals. The current implementation performs two separate DB calls (seller, treasury). For strict ledger atomicity consider implementing DB-side booking.
|
||||||
|
|
||||||
|
Cumulative tax behavior
|
||||||
|
- The system now sums `tax_percent` from the sale region and all ancestor regions (recursive up the region tree). This allows defining different tax rates on up to 6 region levels and summing them for final tax percent.
|
||||||
|
- To avoid reducing seller net by taxes, sale prices are inflated by factor = 1 / (1 - cumulativeTax/100). This way the seller receives the original net and the tax is collected separately.
|
||||||
|
|
||||||
|
Testing
|
||||||
|
- After running the migration, test with a small sale and verify `falukant_log.moneyflow` entries for seller and treasury.
|
||||||
|
|
||||||
@@ -145,6 +145,7 @@ class FalukantController {
|
|||||||
this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds));
|
this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds));
|
||||||
|
|
||||||
this.getRegions = this._wrapWithUser((userId) => this.service.getRegions(userId));
|
this.getRegions = this._wrapWithUser((userId) => this.service.getRegions(userId));
|
||||||
|
this.getBranchTaxes = this._wrapWithUser((userId, req) => this.service.getBranchTaxes(userId, req.params.branchId));
|
||||||
this.getProductPriceInRegion = this._wrapWithUser((userId, req) => {
|
this.getProductPriceInRegion = this._wrapWithUser((userId, req) => {
|
||||||
const productId = parseInt(req.query.productId, 10);
|
const productId = parseInt(req.query.productId, 10);
|
||||||
const regionId = parseInt(req.query.regionId, 10);
|
const regionId = parseInt(req.query.regionId, 10);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_data.region
|
||||||
|
ADD COLUMN IF NOT EXISTS tax_percent numeric NOT NULL DEFAULT 7;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_data.region
|
||||||
|
DROP COLUMN IF EXISTS tax_percent;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
// 1) add backup column for original sell_cost (idempotent)
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_type.product
|
||||||
|
ADD COLUMN IF NOT EXISTS original_sell_cost numeric;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2) if original_sell_cost is not set, copy current sell_cost into it
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
UPDATE falukant_type.product
|
||||||
|
SET original_sell_cost = sell_cost
|
||||||
|
WHERE original_sell_cost IS NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3) compute max cumulative tax across regions and increase sell_cost accordingly
|
||||||
|
// We use the maximum cumulative tax (worst-case) so sellers are neutral across regions.
|
||||||
|
// Formula: neutral_sell = CEIL(original_sell_cost * (1 / (1 - max_total/100)))
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
|
||||||
|
UNION ALL
|
||||||
|
SELECT a.start_id, r.id, r.parent_id, r.tax_percent
|
||||||
|
FROM falukant_data.region r
|
||||||
|
JOIN ancestors a ON r.id = a.parent_id
|
||||||
|
), totals AS (
|
||||||
|
SELECT start_id, COALESCE(SUM(tax_percent), 0) AS total FROM ancestors GROUP BY start_id
|
||||||
|
), mm AS (
|
||||||
|
SELECT COALESCE(MAX(total),0) AS max_total FROM totals
|
||||||
|
)
|
||||||
|
UPDATE falukant_type.product
|
||||||
|
SET sell_cost = CEIL(original_sell_cost * (CASE WHEN (1 - mm.max_total/100) <= 0 THEN 1 ELSE (1 / (1 - mm.max_total/100)) END))
|
||||||
|
FROM mm
|
||||||
|
WHERE original_sell_cost IS NOT NULL;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_type.product
|
||||||
|
DROP COLUMN IF EXISTS sell_cost_min_neutral;
|
||||||
|
`);
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE IF EXISTS falukant_type.product
|
||||||
|
DROP COLUMN IF EXISTS sell_cost_max_neutral;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -30,6 +30,13 @@ RegionData.init({
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: {}
|
defaultValue: {}
|
||||||
}
|
}
|
||||||
|
,
|
||||||
|
taxPercent: {
|
||||||
|
type: DataTypes.DECIMAL,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 7,
|
||||||
|
field: 'tax_percent'
|
||||||
|
}
|
||||||
}, {
|
}, {
|
||||||
sequelize,
|
sequelize,
|
||||||
modelName: 'RegionData',
|
modelName: 'RegionData',
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ ProductType.init({
|
|||||||
sellCost: {
|
sellCost: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false}
|
allowNull: false}
|
||||||
|
,
|
||||||
|
sellCostMinNeutral: {
|
||||||
|
type: DataTypes.DECIMAL,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'sell_cost_min_neutral'
|
||||||
|
},
|
||||||
|
sellCostMaxNeutral: {
|
||||||
|
type: DataTypes.DECIMAL,
|
||||||
|
allowNull: true,
|
||||||
|
field: 'sell_cost_max_neutral'
|
||||||
|
}
|
||||||
}, {
|
}, {
|
||||||
sequelize,
|
sequelize,
|
||||||
modelName: 'ProductType',
|
modelName: 'ProductType',
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ router.post('/politics/open', falukantController.applyForElections);
|
|||||||
router.get('/cities', falukantController.getRegions);
|
router.get('/cities', falukantController.getRegions);
|
||||||
router.get('/products/price-in-region', falukantController.getProductPriceInRegion);
|
router.get('/products/price-in-region', falukantController.getProductPriceInRegion);
|
||||||
router.get('/products/prices-in-cities', falukantController.getProductPricesInCities);
|
router.get('/products/prices-in-cities', falukantController.getProductPricesInCities);
|
||||||
|
router.get('/branches/:branchId/taxes', falukantController.getBranchTaxes);
|
||||||
router.get('/vehicles/types', falukantController.getVehicleTypes);
|
router.get('/vehicles/types', falukantController.getVehicleTypes);
|
||||||
router.post('/vehicles', falukantController.buyVehicles);
|
router.post('/vehicles', falukantController.buyVehicles);
|
||||||
router.get('/vehicles', falukantController.getVehicles);
|
router.get('/vehicles', falukantController.getVehicles);
|
||||||
|
|||||||
@@ -110,6 +110,29 @@ async function calcRegionalSellPrice(product, knowledgeFactor, regionId, worthPe
|
|||||||
return min + (max - min) * (knowledgeFactor / 100);
|
return min + (max - min) * (knowledgeFactor / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sum tax_percent for a region and its ancestors (upwards). Returns numeric percent (e.g. 7.5)
|
||||||
|
async function getCumulativeTaxPercent(regionId) {
|
||||||
|
if (!regionId) return 0;
|
||||||
|
const rows = await sequelize.query(
|
||||||
|
`WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id, parent_id, tax_percent
|
||||||
|
FROM falukant_data.region r
|
||||||
|
WHERE id = :id
|
||||||
|
UNION ALL
|
||||||
|
SELECT reg.id, reg.parent_id, reg.tax_percent
|
||||||
|
FROM falukant_data.region reg
|
||||||
|
JOIN ancestors a ON reg.id = a.parent_id
|
||||||
|
)
|
||||||
|
SELECT COALESCE(SUM(tax_percent),0) AS total FROM ancestors;`,
|
||||||
|
{
|
||||||
|
replacements: { id: regionId },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const val = rows?.[0]?.total ?? 0;
|
||||||
|
return parseFloat(val) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
function calculateMarriageCost(titleOfNobility, age) {
|
function calculateMarriageCost(titleOfNobility, age) {
|
||||||
const minTitle = 1;
|
const minTitle = 1;
|
||||||
const adjustedTitle = titleOfNobility - minTitle + 1;
|
const adjustedTitle = titleOfNobility - minTitle + 1;
|
||||||
@@ -1502,10 +1525,28 @@ class FalukantService extends BaseService {
|
|||||||
if (available < quantity) throw new Error('Not enough inventory available');
|
if (available < quantity) throw new Error('Not enough inventory available');
|
||||||
const item = inventory[0].productType;
|
const item = inventory[0].productType;
|
||||||
const knowledgeVal = item.knowledges?.[0]?.knowledge || 0;
|
const knowledgeVal = item.knowledges?.[0]?.knowledge || 0;
|
||||||
const pricePerUnit = await calcRegionalSellPrice(item, knowledgeVal, branch.regionId);
|
const pricePerUnit = await calcRegionalSellPrice(item, knowledgeVal, branch.regionId);
|
||||||
const revenue = quantity * pricePerUnit;
|
|
||||||
const moneyResult = await updateFalukantUserMoney(user.id, revenue, 'Product sale', user.id);
|
// compute cumulative tax (region + ancestors) and inflate price so seller net is unchanged
|
||||||
if (!moneyResult.success) throw new Error('Failed to update money');
|
const cumulativeTax = await getCumulativeTaxPercent(branch.regionId);
|
||||||
|
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
|
||||||
|
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
|
||||||
|
const revenue = quantity * adjustedPricePerUnit;
|
||||||
|
|
||||||
|
// compute tax and net
|
||||||
|
const taxValue = Math.round((revenue * cumulativeTax / 100) * 100) / 100;
|
||||||
|
const net = Math.round((revenue - taxValue) * 100) / 100;
|
||||||
|
|
||||||
|
// Book net to seller
|
||||||
|
const moneyResult = await updateFalukantUserMoney(user.id, net, `Product sale (net)`, user.id);
|
||||||
|
if (!moneyResult.success) throw new Error('Failed to update money for seller');
|
||||||
|
|
||||||
|
// Book tax to treasury (if configured)
|
||||||
|
const treasuryId = process.env.TREASURY_FALUKANT_USER_ID;
|
||||||
|
if (treasuryId && taxValue > 0) {
|
||||||
|
const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), taxValue, `Sales tax (${cumulativeTax}%)`, user.id);
|
||||||
|
if (!taxResult.success) throw new Error('Failed to update money for treasury');
|
||||||
|
}
|
||||||
let remaining = quantity;
|
let remaining = quantity;
|
||||||
for (const inv of inventory) {
|
for (const inv of inventory) {
|
||||||
if (inv.quantity <= remaining) {
|
if (inv.quantity <= remaining) {
|
||||||
@@ -1573,16 +1614,41 @@ class FalukantService extends BaseService {
|
|||||||
const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0;
|
const knowledgeVal = item.productType.knowledges[0]?.knowledge || 0;
|
||||||
const regionId = item.stock.branch.regionId;
|
const regionId = item.stock.branch.regionId;
|
||||||
const pricePerUnit = await calcRegionalSellPrice(item.productType, knowledgeVal, regionId);
|
const pricePerUnit = await calcRegionalSellPrice(item.productType, knowledgeVal, regionId);
|
||||||
total += item.quantity * pricePerUnit;
|
const cumulativeTax = await getCumulativeTaxPercent(regionId);
|
||||||
|
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
|
||||||
|
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
|
||||||
|
total += item.quantity * adjustedPricePerUnit;
|
||||||
await this.addSellItem(item.stock.branch.id, falukantUser.id, item.productType.id, item.quantity);
|
await this.addSellItem(item.stock.branch.id, falukantUser.id, item.productType.id, item.quantity);
|
||||||
}
|
}
|
||||||
|
// compute tax per region (using cumulative tax per region) and aggregate
|
||||||
|
let totalTax = 0;
|
||||||
|
for (const item of inventory) {
|
||||||
|
const regionId = item.stock.branch.regionId;
|
||||||
|
const region = await RegionData.findOne({ where: { id: regionId } });
|
||||||
|
const cumulativeTax = await getCumulativeTaxPercent(regionId);
|
||||||
|
const pricePerUnit = await calcRegionalSellPrice(item.productType, item.productType.knowledges?.[0]?.knowledge || 0, regionId);
|
||||||
|
const inflationFactor = cumulativeTax >= 100 ? 1 : (1 / (1 - cumulativeTax / 100));
|
||||||
|
const adjustedPricePerUnit = Math.round(pricePerUnit * inflationFactor * 100) / 100;
|
||||||
|
const itemRevenue = item.quantity * adjustedPricePerUnit;
|
||||||
|
const itemTax = Math.round((itemRevenue * cumulativeTax / 100) * 100) / 100;
|
||||||
|
totalTax += itemTax;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalNet = Math.round((total - totalTax) * 100) / 100;
|
||||||
|
|
||||||
const moneyResult = await updateFalukantUserMoney(
|
const moneyResult = await updateFalukantUserMoney(
|
||||||
falukantUser.id,
|
falukantUser.id,
|
||||||
total,
|
totalNet,
|
||||||
'Sell all products',
|
'Sell all products (net)',
|
||||||
falukantUser.id
|
falukantUser.id
|
||||||
);
|
);
|
||||||
if (!moneyResult.success) throw new Error('Failed to update money');
|
if (!moneyResult.success) throw new Error('Failed to update money for seller');
|
||||||
|
|
||||||
|
const treasuryId = process.env.TREASURY_FALUKANT_USER_ID;
|
||||||
|
if (treasuryId && totalTax > 0) {
|
||||||
|
const taxResult = await updateFalukantUserMoney(parseInt(treasuryId, 10), Math.round(totalTax * 100) / 100, `Sales tax (aggregate)`, falukantUser.id);
|
||||||
|
if (!taxResult.success) throw new Error('Failed to update money for treasury');
|
||||||
|
}
|
||||||
for (const item of inventory) {
|
for (const item of inventory) {
|
||||||
await Inventory.destroy({ where: { id: item.id } });
|
await Inventory.destroy({ where: { id: item.id } });
|
||||||
}
|
}
|
||||||
@@ -1617,6 +1683,32 @@ class FalukantService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return tax summary for a branch: total cumulative tax and breakdown per region (region -> parent chain)
|
||||||
|
async getBranchTaxes(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','parentId','taxPercent'] }] });
|
||||||
|
if (!branch) throw new Error('Branch not found');
|
||||||
|
const regionId = branch.regionId;
|
||||||
|
|
||||||
|
// gather region + ancestors with taxPercent
|
||||||
|
const rows = await sequelize.query(`
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id, parent_id, tax_percent, name FROM falukant_data.region WHERE id = :id
|
||||||
|
UNION ALL
|
||||||
|
SELECT r.id, r.parent_id, r.tax_percent, r.name FROM falukant_data.region r JOIN ancestors a ON r.id = a.parent_id
|
||||||
|
) SELECT id, name, parent_id, COALESCE(tax_percent,0) AS tax_percent FROM ancestors;
|
||||||
|
`, { replacements: { id: regionId }, type: sequelize.QueryTypes.SELECT });
|
||||||
|
|
||||||
|
// compute total
|
||||||
|
const total = rows.reduce((sum, r) => sum + parseFloat(r.tax_percent || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
regionId,
|
||||||
|
total: Math.round(total * 100) / 100,
|
||||||
|
breakdown: rows.map(r => ({ id: r.id, name: r.name, parentId: r.parent_id, taxPercent: parseFloat(r.tax_percent || 0) }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async moneyHistory(hashedUserId, page = 1, filter = '') {
|
async moneyHistory(hashedUserId, page = 1, filter = '') {
|
||||||
const u = await getFalukantUserOrFail(hashedUserId);
|
const u = await getFalukantUserOrFail(hashedUserId);
|
||||||
const limit = 25, offset = (page - 1) * limit;
|
const limit = 25, offset = (page - 1) * limit;
|
||||||
|
|||||||
69
backend/sql/update_product_sell_costs.sql
Normal file
69
backend/sql/update_product_sell_costs.sql
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
-- Backup original sell_cost values (just in case)
|
||||||
|
-- Run this once: will add a column original_sell_cost and copy existing sell_cost into it
|
||||||
|
ALTER TABLE IF EXISTS falukant_type.product
|
||||||
|
ADD COLUMN IF NOT EXISTS original_sell_cost numeric;
|
||||||
|
|
||||||
|
UPDATE falukant_type.product
|
||||||
|
SET sell_cost = sell_cost * ((6 * 7 / 100) + 100);
|
||||||
|
|
||||||
|
-- Compute min and max cumulative tax across all regions
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
|
||||||
|
UNION ALL
|
||||||
|
SELECT a.start_id, r.id, r.parent_id, r.tax_percent
|
||||||
|
FROM falukant_data.region r
|
||||||
|
JOIN ancestors a ON r.id = a.parent_id
|
||||||
|
), totals AS (
|
||||||
|
SELECT start_id, COALESCE(SUM(tax_percent),0) AS total FROM ancestors GROUP BY start_id
|
||||||
|
), mm AS (
|
||||||
|
SELECT COALESCE(MIN(total),0) AS min_total, COALESCE(MAX(total),0) AS max_total FROM totals
|
||||||
|
)
|
||||||
|
SELECT * FROM mm;
|
||||||
|
|
||||||
|
-- Choose one of the following update blocks to run:
|
||||||
|
|
||||||
|
-- 1) MIN-STRATEGY: increase sell_cost so that taxes at the minimal cumulative tax have no effect
|
||||||
|
-- (this will set sell_cost = CEIL(original_sell_cost * (1 / (1 - min_total/100))))
|
||||||
|
|
||||||
|
-- BEGIN MIN-STRATEGY
|
||||||
|
-- WITH mm AS (
|
||||||
|
-- WITH RECURSIVE ancestors AS (
|
||||||
|
-- SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT a.start_id, r.id, r.parent_id, r.tax_percent
|
||||||
|
-- FROM falukant_data.region r
|
||||||
|
-- JOIN ancestors a ON r.id = a.parent_id
|
||||||
|
-- ), totals AS (
|
||||||
|
-- SELECT start_id, COALESCE(SUM(tax_percent),0) AS total FROM ancestors GROUP BY start_id
|
||||||
|
-- )
|
||||||
|
-- SELECT COALESCE(MIN(total),0) AS min_total FROM totals
|
||||||
|
-- )
|
||||||
|
-- UPDATE falukant_type.product
|
||||||
|
-- SET sell_cost = CEIL(original_sell_cost * (CASE WHEN (1 - (SELECT min_total FROM mm)/100) <= 0 THEN 1 ELSE (1 / (1 - (SELECT min_total FROM mm)/100)) END));
|
||||||
|
-- END MIN-STRATEGY
|
||||||
|
|
||||||
|
-- 2) MAX-STRATEGY: increase sell_cost so that taxes at the maximal cumulative tax have no effect
|
||||||
|
-- (this will set sell_cost = CEIL(original_sell_cost * (1 / (1 - max_total/100))))
|
||||||
|
|
||||||
|
-- BEGIN MAX-STRATEGY
|
||||||
|
-- WITH mm AS (
|
||||||
|
-- WITH RECURSIVE ancestors AS (
|
||||||
|
-- SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT a.start_id, r.id, r.parent_id, r.tax_percent
|
||||||
|
-- FROM falukant_data.region r
|
||||||
|
-- JOIN ancestors a ON r.id = a.parent_id
|
||||||
|
-- ), totals AS (
|
||||||
|
-- SELECT start_id, COALESCE(SUM(tax_percent),0) AS total FROM ancestors GROUP BY start_id
|
||||||
|
-- )
|
||||||
|
-- SELECT COALESCE(MAX(total),0) AS max_total FROM totals
|
||||||
|
-- )
|
||||||
|
-- UPDATE falukant_type.product
|
||||||
|
-- SET sell_cost = CEIL(original_sell_cost * (CASE WHEN (1 - (SELECT max_total FROM mm)/100) <= 0 THEN 1 ELSE (1 / (1 - (SELECT max_total FROM mm)/100)) END));
|
||||||
|
-- END MAX-STRATEGY
|
||||||
|
|
||||||
|
-- Notes:
|
||||||
|
-- 1) Uncomment exactly one strategy block (MIN or MAX) and run the script.
|
||||||
|
-- 2) The script creates `original_sell_cost` as a backup; keep it for safety.
|
||||||
|
-- 3) CEIL is used to avoid undercompensating due to rounding. If you prefer ROUND use ROUND(...).
|
||||||
|
-- 4) Test on a staging DB first.
|
||||||
@@ -228,10 +228,29 @@ async function initializeFalukantStockTypes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function initializeFalukantProducts() {
|
async function initializeFalukantProducts() {
|
||||||
await ProductType.bulkCreate([
|
// compute min/max cumulative tax across regions
|
||||||
|
const taxRows = await sequelize.query(`
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id AS start_id, id, parent_id, tax_percent FROM falukant_data.region
|
||||||
|
UNION ALL
|
||||||
|
SELECT a.start_id, r.id, r.parent_id, r.tax_percent
|
||||||
|
FROM falukant_data.region r
|
||||||
|
JOIN ancestors a ON r.id = a.parent_id
|
||||||
|
), totals AS (
|
||||||
|
SELECT start_id, COALESCE(SUM(tax_percent), 0) AS total FROM ancestors GROUP BY start_id
|
||||||
|
)
|
||||||
|
SELECT COALESCE(MIN(total),0) AS min_total, COALESCE(MAX(total),0) AS max_total FROM totals
|
||||||
|
`, { type: sequelize.QueryTypes.SELECT });
|
||||||
|
|
||||||
|
const minTax = parseFloat(taxRows?.[0]?.min_total) || 0;
|
||||||
|
const maxTax = parseFloat(taxRows?.[0]?.max_total) || 0;
|
||||||
|
const factorMin = (minTax >= 100) ? 1 : (1 / (1 - minTax / 100));
|
||||||
|
const factorMax = (maxTax >= 100) ? 1 : (1 / (1 - maxTax / 100));
|
||||||
|
|
||||||
|
const baseProducts = [
|
||||||
{ labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 7 },
|
{ labelTr: 'wheat', category: 1, productionTime: 2, sellCost: 7 },
|
||||||
{ labelTr: 'grain', category: 1, productionTime: 2, sellCost: 7 },
|
{ labelTr: 'grain', category: 1, productionTime: 2, sellCost: 7 },
|
||||||
{ labelTr: 'carrot', category: 1, productionTime: 1, sellCost: 46},
|
{ labelTr: 'carrot', category: 1, productionTime: 1, sellCost: 5},
|
||||||
{ labelTr: 'fish', category: 1, productionTime: 2, sellCost: 7 },
|
{ labelTr: 'fish', category: 1, productionTime: 2, sellCost: 7 },
|
||||||
{ labelTr: 'meat', category: 1, productionTime: 2, sellCost: 7 },
|
{ labelTr: 'meat', category: 1, productionTime: 2, sellCost: 7 },
|
||||||
{ labelTr: 'leather', category: 1, productionTime: 2, sellCost: 7 },
|
{ labelTr: 'leather', category: 1, productionTime: 2, sellCost: 7 },
|
||||||
@@ -261,7 +280,15 @@ async function initializeFalukantProducts() {
|
|||||||
{ labelTr: 'shield', category: 4, productionTime: 5, sellCost: 60 },
|
{ labelTr: 'shield', category: 4, productionTime: 5, sellCost: 60 },
|
||||||
{ labelTr: 'horse', category: 5, productionTime: 5, sellCost: 60 },
|
{ labelTr: 'horse', category: 5, productionTime: 5, sellCost: 60 },
|
||||||
{ labelTr: 'ox', category: 5, productionTime: 5, sellCost: 60 },
|
{ labelTr: 'ox', category: 5, productionTime: 5, sellCost: 60 },
|
||||||
], {
|
];
|
||||||
|
|
||||||
|
const productsToInsert = baseProducts.map(p => ({
|
||||||
|
...p,
|
||||||
|
sellCostMinNeutral: Math.ceil(p.sellCost * factorMin),
|
||||||
|
sellCostMaxNeutral: Math.ceil(p.sellCost * factorMax),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await ProductType.bulkCreate(productsToInsert, {
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ const politicalOfficeBenefitTypes = [
|
|||||||
{ tr: 'tax_exemption' },
|
{ tr: 'tax_exemption' },
|
||||||
{ tr: 'guard_protection' },
|
{ tr: 'guard_protection' },
|
||||||
{ tr: 'court_immunity' },
|
{ tr: 'court_immunity' },
|
||||||
|
{ tr: 'set_regionl_tax' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const politicalOffices = [
|
const politicalOffices = [
|
||||||
|
|||||||
@@ -82,6 +82,16 @@
|
|||||||
"title": "Erdbeben",
|
"title": "Erdbeben",
|
||||||
"description": "Ein Erdbeben hat die Region {regionName} erschüttert."
|
"description": "Ein Erdbeben hat die Region {regionName} erschüttert."
|
||||||
}
|
}
|
||||||
|
,
|
||||||
|
"taxes": {
|
||||||
|
"title": "Steuern",
|
||||||
|
"loading": "Steuerdaten werden geladen...",
|
||||||
|
"total": "Gesamte Steuer",
|
||||||
|
"table": {
|
||||||
|
"region": "Region",
|
||||||
|
"taxPercent": "Steuersatz"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"health": {
|
"health": {
|
||||||
@@ -177,7 +187,20 @@
|
|||||||
"inventory": "Inventar",
|
"inventory": "Inventar",
|
||||||
"production": "Produktion",
|
"production": "Produktion",
|
||||||
"storage": "Lager",
|
"storage": "Lager",
|
||||||
"transport": "Transportmittel"
|
"transport": "Transportmittel",
|
||||||
|
"taxes": "Steuern"
|
||||||
|
},
|
||||||
|
"taxes": {
|
||||||
|
"title": "Steuern",
|
||||||
|
"loading": "Steuerdaten werden geladen...",
|
||||||
|
"loadingError": "Fehler beim Laden der Steuerdaten: {error}",
|
||||||
|
"retry": "Erneut laden",
|
||||||
|
"noData": "Keine Steuerdaten verfügbar",
|
||||||
|
"total": "Gesamte Steuer",
|
||||||
|
"table": {
|
||||||
|
"region": "Region",
|
||||||
|
"taxPercent": "Steuersatz"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"selection": {
|
"selection": {
|
||||||
"title": "Niederlassungsauswahl",
|
"title": "Niederlassungsauswahl",
|
||||||
|
|||||||
@@ -172,6 +172,26 @@
|
|||||||
"four_horse_carriage": "Four-horse carriage",
|
"four_horse_carriage": "Four-horse carriage",
|
||||||
"raft": "Raft",
|
"raft": "Raft",
|
||||||
"sailing_ship": "Sailing ship"
|
"sailing_ship": "Sailing ship"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"director": "Director",
|
||||||
|
"inventory": "Inventory",
|
||||||
|
"production": "Production",
|
||||||
|
"storage": "Storage",
|
||||||
|
"transport": "Transport",
|
||||||
|
"taxes": "Taxes"
|
||||||
|
}
|
||||||
|
,"taxes": {
|
||||||
|
"title": "Taxes",
|
||||||
|
"loading": "Loading tax data...",
|
||||||
|
"loadingError": "Failed to load tax data: {error}",
|
||||||
|
"retry": "Retry",
|
||||||
|
"noData": "No tax data available",
|
||||||
|
"total": "Total tax",
|
||||||
|
"table": {
|
||||||
|
"region": "Region",
|
||||||
|
"taxPercent": "Tax %"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nobility": {
|
"nobility": {
|
||||||
@@ -270,6 +290,16 @@
|
|||||||
"details": {
|
"details": {
|
||||||
"title": "Child Details"
|
"title": "Child Details"
|
||||||
}
|
}
|
||||||
|
,
|
||||||
|
"taxes": {
|
||||||
|
"title": "Taxes",
|
||||||
|
"loading": "Loading tax data...",
|
||||||
|
"total": "Total tax",
|
||||||
|
"table": {
|
||||||
|
"region": "Region",
|
||||||
|
"taxPercent": "Tax %"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,11 @@
|
|||||||
|
|
||||||
<!-- Inventar / Verkauf -->
|
<!-- Inventar / Verkauf -->
|
||||||
<div v-else-if="activeTab === 'inventory'" class="branch-tab-pane">
|
<div v-else-if="activeTab === 'inventory'" class="branch-tab-pane">
|
||||||
|
<!-- Tax summary for inventory/sales -->
|
||||||
|
<div v-if="branchTaxes" class="branch-tax-summary">
|
||||||
|
<strong>{{ $t('falukant.branch.taxes.total') }}:</strong>
|
||||||
|
<span>{{ formatPercent(branchTaxes.total) }}</span>
|
||||||
|
</div>
|
||||||
<SaleSection
|
<SaleSection
|
||||||
:branchId="selectedBranch.id"
|
:branchId="selectedBranch.id"
|
||||||
:vehicles="vehicles"
|
:vehicles="vehicles"
|
||||||
@@ -51,6 +56,11 @@
|
|||||||
:products="products"
|
:products="products"
|
||||||
ref="productionSection"
|
ref="productionSection"
|
||||||
/>
|
/>
|
||||||
|
<!-- Tax summary for production -->
|
||||||
|
<div v-if="branchTaxes" class="branch-tax-summary">
|
||||||
|
<strong>{{ $t('falukant.branch.taxes.total') }}:</strong>
|
||||||
|
<span>{{ formatPercent(branchTaxes.total) }}</span>
|
||||||
|
</div>
|
||||||
<RevenueSection
|
<RevenueSection
|
||||||
:products="products"
|
:products="products"
|
||||||
:calculateProductRevenue="calculateProductRevenue"
|
:calculateProductRevenue="calculateProductRevenue"
|
||||||
@@ -60,6 +70,41 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Taxes Übersicht -->
|
||||||
|
<div v-else-if="activeTab === 'taxes'" class="branch-tab-pane">
|
||||||
|
<h3>{{ $t('falukant.branch.taxes.title') }}</h3>
|
||||||
|
<div v-if="branchTaxesLoading">
|
||||||
|
<p>{{ $t('falukant.branch.taxes.loading') }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="branchTaxesError">
|
||||||
|
<p>{{ $t('falukant.branch.taxes.loadingError', { error: branchTaxesError }) }}</p>
|
||||||
|
<button @click="loadBranchTaxes">{{ $t('falukant.branch.taxes.retry') }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!branchTaxes || !branchTaxes.breakdown || branchTaxes.breakdown.length === 0">
|
||||||
|
<p>{{ $t('falukant.branch.taxes.noData') }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<p>
|
||||||
|
<strong>{{ $t('falukant.branch.taxes.total') }}:</strong>
|
||||||
|
<span>{{ formatCurrency(branchTaxes.total) }}</span>
|
||||||
|
</p>
|
||||||
|
<table class="taxes-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t('falukant.branch.taxes.table.region') }}</th>
|
||||||
|
<th>{{ $t('falukant.branch.taxes.table.taxPercent') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in branchTaxes.breakdown" :key="r.id">
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td>{{ r.taxPercent }}%</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Lager -->
|
<!-- Lager -->
|
||||||
<div v-else-if="activeTab === 'storage'" class="branch-tab-pane">
|
<div v-else-if="activeTab === 'storage'" class="branch-tab-pane">
|
||||||
<StorageSection :branchId="selectedBranch.id" ref="storageSection" />
|
<StorageSection :branchId="selectedBranch.id" ref="storageSection" />
|
||||||
@@ -318,6 +363,7 @@ export default {
|
|||||||
tabs: [
|
tabs: [
|
||||||
{ value: 'production', label: 'falukant.branch.tabs.production' },
|
{ value: 'production', label: 'falukant.branch.tabs.production' },
|
||||||
{ value: 'inventory', label: 'falukant.branch.tabs.inventory' },
|
{ value: 'inventory', label: 'falukant.branch.tabs.inventory' },
|
||||||
|
{ value: 'taxes', label: 'falukant.branch.tabs.taxes' },
|
||||||
{ value: 'director', label: 'falukant.branch.tabs.director' },
|
{ value: 'director', label: 'falukant.branch.tabs.director' },
|
||||||
{ value: 'storage', label: 'falukant.branch.tabs.storage' },
|
{ value: 'storage', label: 'falukant.branch.tabs.storage' },
|
||||||
{ value: 'transport', label: 'falukant.branch.tabs.transport' },
|
{ value: 'transport', label: 'falukant.branch.tabs.transport' },
|
||||||
@@ -341,6 +387,9 @@ export default {
|
|||||||
vehicleIds: [],
|
vehicleIds: [],
|
||||||
totalCost: null,
|
totalCost: null,
|
||||||
},
|
},
|
||||||
|
branchTaxes: null,
|
||||||
|
branchTaxesLoading: false,
|
||||||
|
branchTaxesError: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -401,6 +450,8 @@ export default {
|
|||||||
this.socket.on('transport_arrived', (data) => this.handleEvent({ event: 'transport_arrived', ...data }));
|
this.socket.on('transport_arrived', (data) => this.handleEvent({ event: 'transport_arrived', ...data }));
|
||||||
this.socket.on('inventory_updated', (data) => this.handleEvent({ event: 'inventory_updated', ...data }));
|
this.socket.on('inventory_updated', (data) => this.handleEvent({ event: 'inventory_updated', ...data }));
|
||||||
}
|
}
|
||||||
|
// Load taxes for the initially selected branch (if any)
|
||||||
|
await this.loadBranchTaxes();
|
||||||
},
|
},
|
||||||
|
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
@@ -416,6 +467,23 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
activeTab(newVal) {
|
||||||
|
if (newVal === 'taxes') {
|
||||||
|
this.loadBranchTaxes();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectedBranch: {
|
||||||
|
handler(newBranch) {
|
||||||
|
// if taxes tab is active, refresh when branch changes
|
||||||
|
if (this.activeTab === 'taxes' && newBranch && newBranch.id) {
|
||||||
|
this.loadBranchTaxes();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deep: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
async loadBranches() {
|
async loadBranches() {
|
||||||
try {
|
try {
|
||||||
@@ -446,6 +514,29 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loadBranchTaxes() {
|
||||||
|
if (!this.selectedBranch || !this.selectedBranch.id) {
|
||||||
|
this.branchTaxes = null;
|
||||||
|
this.branchTaxesError = null;
|
||||||
|
this.branchTaxesLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.branchTaxesLoading = true;
|
||||||
|
this.branchTaxesError = null;
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/api/falukant/branches/${this.selectedBranch.id}/taxes`);
|
||||||
|
this.branchTaxes = res.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load branch taxes', err);
|
||||||
|
// Try to surface a useful error message
|
||||||
|
const remoteMsg = err?.response?.data?.error || err?.message || String(err);
|
||||||
|
this.branchTaxes = null;
|
||||||
|
this.branchTaxesError = remoteMsg;
|
||||||
|
} finally {
|
||||||
|
this.branchTaxesLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async onBranchSelected(newBranch) {
|
async onBranchSelected(newBranch) {
|
||||||
this.selectedBranch = newBranch;
|
this.selectedBranch = newBranch;
|
||||||
// Branches neu laden, um das Wetter zu aktualisieren
|
// Branches neu laden, um das Wetter zu aktualisieren
|
||||||
@@ -467,6 +558,9 @@ export default {
|
|||||||
this.$refs.revenueSection?.refresh && this.$refs.revenueSection.refresh();
|
this.$refs.revenueSection?.refresh && this.$refs.revenueSection.refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// load tax info for this branch
|
||||||
|
this.loadBranchTaxes();
|
||||||
|
|
||||||
// Beim Initial-Laden sicherstellen, dass ein Tab-Inhalt sichtbar ist
|
// Beim Initial-Laden sicherstellen, dass ein Tab-Inhalt sichtbar ist
|
||||||
if (this.selectedBranch && !this.activeTab) {
|
if (this.selectedBranch && !this.activeTab) {
|
||||||
this.activeTab = 'director';
|
this.activeTab = 'director';
|
||||||
@@ -501,6 +595,14 @@ export default {
|
|||||||
this.productPricesCache = prices;
|
this.productPricesCache = prices;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
formatPercent(value) {
|
||||||
|
if (value === null || value === undefined) return '—';
|
||||||
|
// Ensure numeric and format with locale with max 2 fraction digits
|
||||||
|
const num = typeof value === 'string' ? parseFloat(value) : Number(value);
|
||||||
|
if (Number.isNaN(num)) return '—';
|
||||||
|
return num.toLocaleString(undefined, { maximumFractionDigits: 2 }) + '%';
|
||||||
|
},
|
||||||
|
|
||||||
async createBranch() {
|
async createBranch() {
|
||||||
await this.loadBranches();
|
await this.loadBranches();
|
||||||
// Nach dem Anlegen eines neuen Branches automatisch den
|
// Nach dem Anlegen eines neuen Branches automatisch den
|
||||||
@@ -933,10 +1035,17 @@ export default {
|
|||||||
formatMoney(amount) {
|
formatMoney(amount) {
|
||||||
if (amount == null) return '';
|
if (amount == null) return '';
|
||||||
try {
|
try {
|
||||||
return amount.toLocaleString(undefined, {
|
return this.formatCurrency(amount);
|
||||||
minimumFractionDigits: 0,
|
} catch (e) {
|
||||||
maximumFractionDigits: 0,
|
return String(amount);
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatCurrency(amount) {
|
||||||
|
if (amount == null) return '';
|
||||||
|
try {
|
||||||
|
// Use euro currency formatting with locale-sensitive digits
|
||||||
|
return Number(amount).toLocaleString(undefined, { style: 'currency', currency: 'EUR' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return String(amount);
|
return String(amount);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,9 +97,6 @@ export default {
|
|||||||
}
|
}
|
||||||
return translation !== key ? translation : activity;
|
return translation !== key ? translation : activity;
|
||||||
},
|
},
|
||||||
locale() {
|
|
||||||
return window.navigator.language || 'en-US';
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user