🤝 Subunternehmer-API: Partnerschaften, Aufträge, Stundenzettel, Abrechnung
This commit is contained in:
@@ -8,6 +8,7 @@ import { timesheetsRouter } from "./routes/timesheets.ts";
|
|||||||
import { modulesRouter } from "./routes/modules.ts";
|
import { modulesRouter } from "./routes/modules.ts";
|
||||||
import { organizationsRouter } from "./routes/organizations.ts";
|
import { organizationsRouter } from "./routes/organizations.ts";
|
||||||
import { adminRouter } from "./routes/admin.ts";
|
import { adminRouter } from "./routes/admin.ts";
|
||||||
|
import { partnershipsRouter } from "./routes/partnerships.ts";
|
||||||
import { errorHandler } from "./middleware/error.ts";
|
import { errorHandler } from "./middleware/error.ts";
|
||||||
import { requestLogger } from "./middleware/logger.ts";
|
import { requestLogger } from "./middleware/logger.ts";
|
||||||
import { initDB } from "./db/postgres.ts";
|
import { initDB } from "./db/postgres.ts";
|
||||||
@@ -47,6 +48,8 @@ app.use(organizationsRouter.routes());
|
|||||||
app.use(organizationsRouter.allowedMethods());
|
app.use(organizationsRouter.allowedMethods());
|
||||||
app.use(adminRouter.routes());
|
app.use(adminRouter.routes());
|
||||||
app.use(adminRouter.allowedMethods());
|
app.use(adminRouter.allowedMethods());
|
||||||
|
app.use(partnershipsRouter.routes());
|
||||||
|
app.use(partnershipsRouter.allowedMethods());
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.use((ctx) => {
|
app.use((ctx) => {
|
||||||
|
|||||||
673
src/routes/partnerships.ts
Normal file
673
src/routes/partnerships.ts
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { query, queryOne, execute } from "../db/postgres.ts";
|
||||||
|
import { AppError } from "../middleware/error.ts";
|
||||||
|
import { authMiddleware, requireChef } from "../middleware/auth.ts";
|
||||||
|
|
||||||
|
export const partnershipsRouter = new Router({ prefix: "/api/partnerships" });
|
||||||
|
|
||||||
|
// ============ PARTNERSHIP MANAGEMENT ============
|
||||||
|
|
||||||
|
// Get all partnerships (as contractor or subcontractor)
|
||||||
|
partnershipsRouter.get("/", authMiddleware, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
|
||||||
|
const partnerships = await query<any>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as contractor_name, c.slug as contractor_slug,
|
||||||
|
s.name as subcontractor_name, s.slug as subcontractor_slug,
|
||||||
|
(SELECT COUNT(*) FROM shared_orders WHERE partnership_id = p.id) as shared_orders_count,
|
||||||
|
(SELECT COUNT(*) FROM partnership_rates WHERE partnership_id = p.id) as rates_count
|
||||||
|
FROM org_partnerships p
|
||||||
|
JOIN organizations c ON p.contractor_org_id = c.id
|
||||||
|
JOIN organizations s ON p.subcontractor_org_id = s.id
|
||||||
|
WHERE p.contractor_org_id = $1 OR p.subcontractor_org_id = $1
|
||||||
|
ORDER BY p.created_at DESC`,
|
||||||
|
[orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark role for each partnership
|
||||||
|
const result = partnerships.map(p => ({
|
||||||
|
...p,
|
||||||
|
my_role: p.contractor_org_id === orgId ? 'contractor' : 'subcontractor'
|
||||||
|
}));
|
||||||
|
|
||||||
|
ctx.response.body = { partnerships: result };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single partnership with details
|
||||||
|
partnershipsRouter.get("/:id", authMiddleware, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const partnershipId = ctx.params.id;
|
||||||
|
|
||||||
|
const partnership = await queryOne<any>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as contractor_name, c.slug as contractor_slug,
|
||||||
|
s.name as subcontractor_name, s.slug as subcontractor_slug
|
||||||
|
FROM org_partnerships p
|
||||||
|
JOIN organizations c ON p.contractor_org_id = c.id
|
||||||
|
JOIN organizations s ON p.subcontractor_org_id = s.id
|
||||||
|
WHERE p.id = $1 AND (p.contractor_org_id = $2 OR p.subcontractor_org_id = $2)`,
|
||||||
|
[partnershipId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!partnership) {
|
||||||
|
throw new AppError("Partnerschaft nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get rates
|
||||||
|
const rates = await query<any>(
|
||||||
|
`SELECT * FROM partnership_rates WHERE partnership_id = $1 ORDER BY is_default DESC, name`,
|
||||||
|
[partnershipId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get recent shared orders
|
||||||
|
const recentOrders = await query<any>(
|
||||||
|
`SELECT so.*, o.title as order_title, o.start_time, o.end_time
|
||||||
|
FROM shared_orders so
|
||||||
|
JOIN orders o ON so.original_order_id = o.id
|
||||||
|
WHERE so.partnership_id = $1
|
||||||
|
ORDER BY so.created_at DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
[partnershipId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
partnership: {
|
||||||
|
...partnership,
|
||||||
|
my_role: partnership.contractor_org_id === orgId ? 'contractor' : 'subcontractor'
|
||||||
|
},
|
||||||
|
rates,
|
||||||
|
recentOrders
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invite subcontractor (by org slug)
|
||||||
|
partnershipsRouter.post("/invite", requireChef, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { subcontractor_slug, notes } = body;
|
||||||
|
|
||||||
|
if (!subcontractor_slug) {
|
||||||
|
throw new AppError("Subunternehmer-Kürzel erforderlich", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find subcontractor org
|
||||||
|
const subOrg = await queryOne<{ id: string; name: string }>(
|
||||||
|
"SELECT id, name FROM organizations WHERE slug = $1",
|
||||||
|
[subcontractor_slug]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!subOrg) {
|
||||||
|
throw new AppError("Organisation nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subOrg.id === orgId) {
|
||||||
|
throw new AppError("Sie können sich nicht selbst einladen", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if partnership exists
|
||||||
|
const existing = await queryOne<{ id: string; status: string }>(
|
||||||
|
`SELECT id, status FROM org_partnerships
|
||||||
|
WHERE contractor_org_id = $1 AND subcontractor_org_id = $2`,
|
||||||
|
[orgId, subOrg.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === 'active') {
|
||||||
|
throw new AppError("Partnerschaft existiert bereits", 409);
|
||||||
|
}
|
||||||
|
// Reactivate pending/paused/terminated
|
||||||
|
await execute(
|
||||||
|
`UPDATE org_partnerships SET status = 'pending', invited_by = $1, notes = $2, updated_at = NOW()
|
||||||
|
WHERE id = $3`,
|
||||||
|
[userId, notes || null, existing.id]
|
||||||
|
);
|
||||||
|
ctx.response.body = { message: "Einladung erneut gesendet", partnershipId: existing.id };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create partnership
|
||||||
|
const result = await queryOne<{ id: string }>(
|
||||||
|
`INSERT INTO org_partnerships (contractor_org_id, subcontractor_org_id, status, invited_by, notes)
|
||||||
|
VALUES ($1, $2, 'pending', $3, $4)
|
||||||
|
RETURNING id`,
|
||||||
|
[orgId, subOrg.id, userId, notes || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
message: `Einladung an ${subOrg.name} gesendet`,
|
||||||
|
partnershipId: result?.id
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accept/Decline partnership invitation
|
||||||
|
partnershipsRouter.post("/:id/respond", requireChef, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const partnershipId = ctx.params.id;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { accept } = body;
|
||||||
|
|
||||||
|
const partnership = await queryOne<any>(
|
||||||
|
`SELECT * FROM org_partnerships WHERE id = $1 AND subcontractor_org_id = $2 AND status = 'pending'`,
|
||||||
|
[partnershipId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!partnership) {
|
||||||
|
throw new AppError("Einladung nicht gefunden oder bereits beantwortet", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accept) {
|
||||||
|
await execute(
|
||||||
|
`UPDATE org_partnerships SET status = 'active', accepted_by = $1, accepted_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $2`,
|
||||||
|
[userId, partnershipId]
|
||||||
|
);
|
||||||
|
ctx.response.body = { message: "Partnerschaft aktiviert" };
|
||||||
|
} else {
|
||||||
|
await execute(
|
||||||
|
`UPDATE org_partnerships SET status = 'terminated', updated_at = NOW() WHERE id = $1`,
|
||||||
|
[partnershipId]
|
||||||
|
);
|
||||||
|
ctx.response.body = { message: "Einladung abgelehnt" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ RATES ============
|
||||||
|
|
||||||
|
// Add rate to partnership
|
||||||
|
partnershipsRouter.post("/:id/rates", requireChef, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const partnershipId = ctx.params.id;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
// Verify partnership and that user is contractor
|
||||||
|
const partnership = await queryOne<any>(
|
||||||
|
`SELECT * FROM org_partnerships WHERE id = $1 AND contractor_org_id = $2`,
|
||||||
|
[partnershipId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!partnership) {
|
||||||
|
throw new AppError("Partnerschaft nicht gefunden oder keine Berechtigung", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, rate_type, amount, description, is_default, valid_from, valid_until } = body;
|
||||||
|
|
||||||
|
if (!name || !amount) {
|
||||||
|
throw new AppError("Name und Betrag erforderlich", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If setting as default, unset other defaults
|
||||||
|
if (is_default) {
|
||||||
|
await execute(
|
||||||
|
`UPDATE partnership_rates SET is_default = false WHERE partnership_id = $1`,
|
||||||
|
[partnershipId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne<{ id: string }>(
|
||||||
|
`INSERT INTO partnership_rates (partnership_id, name, rate_type, amount_cents, description, is_default, valid_from, valid_until)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
[partnershipId, name, rate_type || 'hourly', Math.round(amount * 100), description || null,
|
||||||
|
is_default || false, valid_from || null, valid_until || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { message: "Satz hinzugefügt", rateId: result?.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update rate
|
||||||
|
partnershipsRouter.put("/:id/rates/:rateId", requireChef, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const { id: partnershipId, rateId } = ctx.params;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
const partnership = await queryOne<any>(
|
||||||
|
`SELECT * FROM org_partnerships WHERE id = $1 AND contractor_org_id = $2`,
|
||||||
|
[partnershipId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!partnership) {
|
||||||
|
throw new AppError("Keine Berechtigung", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, amount, description, is_default, valid_until } = body;
|
||||||
|
|
||||||
|
if (is_default) {
|
||||||
|
await execute(
|
||||||
|
`UPDATE partnership_rates SET is_default = false WHERE partnership_id = $1 AND id != $2`,
|
||||||
|
[partnershipId, rateId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
`UPDATE partnership_rates
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
amount_cents = COALESCE($2, amount_cents),
|
||||||
|
description = COALESCE($3, description),
|
||||||
|
is_default = COALESCE($4, is_default),
|
||||||
|
valid_until = $5,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $6 AND partnership_id = $7`,
|
||||||
|
[name, amount ? Math.round(amount * 100) : null, description, is_default, valid_until || null, rateId, partnershipId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "Satz aktualisiert" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ SHARED ORDERS ============
|
||||||
|
|
||||||
|
// Share order with subcontractor
|
||||||
|
partnershipsRouter.post("/:id/orders", requireChef, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const partnershipId = ctx.params.id;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
const { order_id, rate_id, required_staff, notes } = body;
|
||||||
|
|
||||||
|
if (!order_id) {
|
||||||
|
throw new AppError("Auftrags-ID erforderlich", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify partnership
|
||||||
|
const partnership = await queryOne<any>(
|
||||||
|
`SELECT * FROM org_partnerships WHERE id = $1 AND contractor_org_id = $2 AND status = 'active'`,
|
||||||
|
[partnershipId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!partnership) {
|
||||||
|
throw new AppError("Aktive Partnerschaft nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify order belongs to contractor
|
||||||
|
const order = await queryOne<any>(
|
||||||
|
`SELECT * FROM orders WHERE id = $1 AND org_id = $2`,
|
||||||
|
[order_id, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new AppError("Auftrag nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already shared
|
||||||
|
const existing = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM shared_orders WHERE partnership_id = $1 AND original_order_id = $2`,
|
||||||
|
[partnershipId, order_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new AppError("Auftrag bereits geteilt", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne<{ id: string }>(
|
||||||
|
`INSERT INTO shared_orders (partnership_id, original_order_id, rate_id, required_staff, contractor_notes, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'requested')
|
||||||
|
RETURNING id`,
|
||||||
|
[partnershipId, order_id, rate_id || null, required_staff || 1, notes || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { message: "Auftrag geteilt", sharedOrderId: result?.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get shared orders for partnership
|
||||||
|
partnershipsRouter.get("/:id/orders", authMiddleware, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const partnershipId = ctx.params.id;
|
||||||
|
const status = ctx.request.url.searchParams.get("status");
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
const partnership = await queryOne<any>(
|
||||||
|
`SELECT * FROM org_partnerships WHERE id = $1 AND (contractor_org_id = $2 OR subcontractor_org_id = $2)`,
|
||||||
|
[partnershipId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!partnership) {
|
||||||
|
throw new AppError("Partnerschaft nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let whereClause = "WHERE so.partnership_id = $1";
|
||||||
|
const params: unknown[] = [partnershipId];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += " AND so.status = $2";
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orders = await query<any>(
|
||||||
|
`SELECT so.*,
|
||||||
|
o.title, o.description, o.location, o.address, o.start_time, o.end_time, o.status as order_status,
|
||||||
|
r.name as rate_name, r.amount_cents as rate_amount
|
||||||
|
FROM shared_orders so
|
||||||
|
JOIN orders o ON so.original_order_id = o.id
|
||||||
|
LEFT JOIN partnership_rates r ON so.rate_id = r.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY o.start_time DESC NULLS LAST`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
orders,
|
||||||
|
my_role: partnership.contractor_org_id === orgId ? 'contractor' : 'subcontractor'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Respond to shared order (subcontractor)
|
||||||
|
partnershipsRouter.post("/:id/orders/:orderId/respond", requireChef, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const { id: partnershipId, orderId } = ctx.params;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { accept, notes } = body;
|
||||||
|
|
||||||
|
// Verify subcontractor
|
||||||
|
const partnership = await queryOne<any>(
|
||||||
|
`SELECT * FROM org_partnerships WHERE id = $1 AND subcontractor_org_id = $2 AND status = 'active'`,
|
||||||
|
[partnershipId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!partnership) {
|
||||||
|
throw new AppError("Keine Berechtigung", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedOrder = await queryOne<any>(
|
||||||
|
`SELECT * FROM shared_orders WHERE id = $1 AND partnership_id = $2 AND status = 'requested'`,
|
||||||
|
[orderId, partnershipId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sharedOrder) {
|
||||||
|
throw new AppError("Anfrage nicht gefunden oder bereits beantwortet", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = accept ? 'accepted' : 'declined';
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
`UPDATE shared_orders SET status = $1, subcontractor_notes = $2, responded_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $3`,
|
||||||
|
[newStatus, notes || null, orderId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { message: accept ? "Auftrag angenommen" : "Auftrag abgelehnt" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ TIMESHEETS ============
|
||||||
|
|
||||||
|
// Submit timesheet for shared order (subcontractor)
|
||||||
|
partnershipsRouter.post("/:id/orders/:orderId/timesheets", authMiddleware, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const { id: partnershipId, orderId } = ctx.params;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { timesheet_id } = body;
|
||||||
|
|
||||||
|
// Verify subcontractor
|
||||||
|
const partnership = await queryOne<any>(
|
||||||
|
`SELECT * FROM org_partnerships WHERE id = $1 AND subcontractor_org_id = $2 AND status = 'active'`,
|
||||||
|
[partnershipId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!partnership) {
|
||||||
|
throw new AppError("Keine Berechtigung", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify shared order
|
||||||
|
const sharedOrder = await queryOne<any>(
|
||||||
|
`SELECT so.*, r.amount_cents as rate_amount
|
||||||
|
FROM shared_orders so
|
||||||
|
LEFT JOIN partnership_rates r ON so.rate_id = r.id
|
||||||
|
WHERE so.id = $1 AND so.partnership_id = $2 AND so.status = 'accepted'`,
|
||||||
|
[orderId, partnershipId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sharedOrder) {
|
||||||
|
throw new AppError("Geteilter Auftrag nicht gefunden oder nicht akzeptiert", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify timesheet belongs to subcontractor org
|
||||||
|
const timesheet = await queryOne<any>(
|
||||||
|
`SELECT t.* FROM timesheets t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.id = $1 AND u.org_id = $2`,
|
||||||
|
[timesheet_id, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!timesheet) {
|
||||||
|
throw new AppError("Stundenzettel nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate amount
|
||||||
|
const hours = timesheet.hours_worked || 0;
|
||||||
|
const calculatedAmount = sharedOrder.rate_amount
|
||||||
|
? Math.round(hours * sharedOrder.rate_amount)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const result = await queryOne<{ id: string }>(
|
||||||
|
`INSERT INTO partnership_timesheets (shared_order_id, timesheet_id, rate_id, calculated_amount_cents)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id`,
|
||||||
|
[orderId, timesheet_id, sharedOrder.rate_id, calculatedAmount]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
message: "Stundenzettel eingereicht",
|
||||||
|
partnershipTimesheetId: result?.id,
|
||||||
|
calculatedAmount: calculatedAmount ? calculatedAmount / 100 : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get timesheets for partnership
|
||||||
|
partnershipsRouter.get("/:id/timesheets", authMiddleware, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const partnershipId = ctx.params.id;
|
||||||
|
const status = ctx.request.url.searchParams.get("status");
|
||||||
|
const from = ctx.request.url.searchParams.get("from");
|
||||||
|
const to = ctx.request.url.searchParams.get("to");
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
const partnership = await queryOne<any>(
|
||||||
|
`SELECT * FROM org_partnerships WHERE id = $1 AND (contractor_org_id = $2 OR subcontractor_org_id = $2)`,
|
||||||
|
[partnershipId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!partnership) {
|
||||||
|
throw new AppError("Partnerschaft nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let whereClause = "WHERE so.partnership_id = $1";
|
||||||
|
const params: unknown[] = [partnershipId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND pt.approval_status = $${paramIndex}`;
|
||||||
|
params.push(status);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from) {
|
||||||
|
whereClause += ` AND t.work_date >= $${paramIndex}`;
|
||||||
|
params.push(from);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
whereClause += ` AND t.work_date <= $${paramIndex}`;
|
||||||
|
params.push(to);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timesheets = await query<any>(
|
||||||
|
`SELECT pt.*,
|
||||||
|
t.work_date, t.start_time, t.end_time, t.hours_worked, t.photo_url,
|
||||||
|
u.first_name || ' ' || u.last_name as worker_name,
|
||||||
|
o.title as order_title,
|
||||||
|
r.name as rate_name, r.amount_cents as rate_amount
|
||||||
|
FROM partnership_timesheets pt
|
||||||
|
JOIN shared_orders so ON pt.shared_order_id = so.id
|
||||||
|
JOIN timesheets t ON pt.timesheet_id = t.id
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
JOIN orders o ON so.original_order_id = o.id
|
||||||
|
LEFT JOIN partnership_rates r ON pt.rate_id = r.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY t.work_date DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totals = {
|
||||||
|
pending: 0,
|
||||||
|
approved: 0,
|
||||||
|
pendingAmount: 0,
|
||||||
|
approvedAmount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const ts of timesheets) {
|
||||||
|
if (ts.approval_status === 'pending') {
|
||||||
|
totals.pending++;
|
||||||
|
totals.pendingAmount += ts.calculated_amount_cents || 0;
|
||||||
|
} else if (ts.approval_status === 'approved') {
|
||||||
|
totals.approved++;
|
||||||
|
totals.approvedAmount += ts.calculated_amount_cents || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
timesheets,
|
||||||
|
totals: {
|
||||||
|
...totals,
|
||||||
|
pendingAmount: totals.pendingAmount / 100,
|
||||||
|
approvedAmount: totals.approvedAmount / 100
|
||||||
|
},
|
||||||
|
my_role: partnership.contractor_org_id === orgId ? 'contractor' : 'subcontractor'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Approve/Reject partnership timesheet (contractor)
|
||||||
|
partnershipsRouter.post("/:id/timesheets/:tsId/review", requireChef, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const { id: partnershipId, tsId } = ctx.params;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { approve, dispute_reason } = body;
|
||||||
|
|
||||||
|
// Verify contractor
|
||||||
|
const partnership = await queryOne<any>(
|
||||||
|
`SELECT * FROM org_partnerships WHERE id = $1 AND contractor_org_id = $2 AND status = 'active'`,
|
||||||
|
[partnershipId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!partnership) {
|
||||||
|
throw new AppError("Keine Berechtigung", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = approve ? 'approved' : 'disputed';
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
`UPDATE partnership_timesheets
|
||||||
|
SET approval_status = $1, approved_by = $2, approved_at = NOW(), dispute_reason = $3, updated_at = NOW()
|
||||||
|
WHERE id = $4`,
|
||||||
|
[newStatus, userId, approve ? null : (dispute_reason || 'Keine Angabe'), tsId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { message: approve ? "Stundenzettel genehmigt" : "Stundenzettel beanstandet" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ BILLING SUMMARY ============
|
||||||
|
|
||||||
|
// Get billing summary for partnership
|
||||||
|
partnershipsRouter.get("/:id/billing", authMiddleware, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const partnershipId = ctx.params.id;
|
||||||
|
const from = ctx.request.url.searchParams.get("from");
|
||||||
|
const to = ctx.request.url.searchParams.get("to");
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
const partnership = await queryOne<any>(
|
||||||
|
`SELECT p.*, c.name as contractor_name, s.name as subcontractor_name
|
||||||
|
FROM org_partnerships p
|
||||||
|
JOIN organizations c ON p.contractor_org_id = c.id
|
||||||
|
JOIN organizations s ON p.subcontractor_org_id = s.id
|
||||||
|
WHERE p.id = $1 AND (p.contractor_org_id = $2 OR p.subcontractor_org_id = $2)`,
|
||||||
|
[partnershipId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!partnership) {
|
||||||
|
throw new AppError("Partnerschaft nicht gefunden", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build date filter
|
||||||
|
let dateFilter = "";
|
||||||
|
const params: unknown[] = [partnershipId];
|
||||||
|
|
||||||
|
if (from) {
|
||||||
|
dateFilter += ` AND t.work_date >= $${params.length + 1}`;
|
||||||
|
params.push(from);
|
||||||
|
}
|
||||||
|
if (to) {
|
||||||
|
dateFilter += ` AND t.work_date <= $${params.length + 1}`;
|
||||||
|
params.push(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get summary by status
|
||||||
|
const summary = await queryOne<any>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) FILTER (WHERE pt.approval_status = 'pending') as pending_count,
|
||||||
|
COUNT(*) FILTER (WHERE pt.approval_status = 'approved') as approved_count,
|
||||||
|
COUNT(*) FILTER (WHERE pt.approval_status = 'disputed') as disputed_count,
|
||||||
|
COALESCE(SUM(pt.calculated_amount_cents) FILTER (WHERE pt.approval_status = 'pending'), 0) as pending_amount,
|
||||||
|
COALESCE(SUM(pt.calculated_amount_cents) FILTER (WHERE pt.approval_status = 'approved'), 0) as approved_amount,
|
||||||
|
COALESCE(SUM(t.hours_worked) FILTER (WHERE pt.approval_status = 'approved'), 0) as approved_hours
|
||||||
|
FROM partnership_timesheets pt
|
||||||
|
JOIN shared_orders so ON pt.shared_order_id = so.id
|
||||||
|
JOIN timesheets t ON pt.timesheet_id = t.id
|
||||||
|
WHERE so.partnership_id = $1 ${dateFilter}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get breakdown by rate
|
||||||
|
const byRate = await query<any>(
|
||||||
|
`SELECT
|
||||||
|
r.name as rate_name,
|
||||||
|
r.amount_cents as rate_amount,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COALESCE(SUM(t.hours_worked), 0) as total_hours,
|
||||||
|
COALESCE(SUM(pt.calculated_amount_cents), 0) as total_amount
|
||||||
|
FROM partnership_timesheets pt
|
||||||
|
JOIN shared_orders so ON pt.shared_order_id = so.id
|
||||||
|
JOIN timesheets t ON pt.timesheet_id = t.id
|
||||||
|
LEFT JOIN partnership_rates r ON pt.rate_id = r.id
|
||||||
|
WHERE so.partnership_id = $1 AND pt.approval_status = 'approved' ${dateFilter}
|
||||||
|
GROUP BY r.id, r.name, r.amount_cents
|
||||||
|
ORDER BY total_amount DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
partnership: {
|
||||||
|
id: partnership.id,
|
||||||
|
contractor_name: partnership.contractor_name,
|
||||||
|
subcontractor_name: partnership.subcontractor_name,
|
||||||
|
my_role: partnership.contractor_org_id === orgId ? 'contractor' : 'subcontractor'
|
||||||
|
},
|
||||||
|
period: { from, to },
|
||||||
|
summary: {
|
||||||
|
pending: {
|
||||||
|
count: parseInt(summary?.pending_count || '0'),
|
||||||
|
amount: (summary?.pending_amount || 0) / 100
|
||||||
|
},
|
||||||
|
approved: {
|
||||||
|
count: parseInt(summary?.approved_count || '0'),
|
||||||
|
amount: (summary?.approved_amount || 0) / 100,
|
||||||
|
hours: parseFloat(summary?.approved_hours || '0')
|
||||||
|
},
|
||||||
|
disputed: {
|
||||||
|
count: parseInt(summary?.disputed_count || '0')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
byRate: byRate.map(r => ({
|
||||||
|
...r,
|
||||||
|
rate_amount: r.rate_amount / 100,
|
||||||
|
total_hours: parseFloat(r.total_hours),
|
||||||
|
total_amount: r.total_amount / 100
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user