Merge pull request #1 from kihaneseifu/feat/k8s-deployment-adaptation
refactor: replace Supabase SDK with self-hosted REST API client layer
This commit is contained in:
commit
5fb22cb120
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY iloom-flatten/package.json iloom-flatten/package-lock.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
COPY iloom-flatten/ .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY iloom-flatten/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 3015
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
42
nginx.conf
Normal file
42
nginx.conf
Normal file
@ -0,0 +1,42 @@
|
||||
server {
|
||||
listen 3015;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
# API 反向代理
|
||||
location /api/ {
|
||||
proxy_pass http://gateway:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket 反向代理
|
||||
location /ws/ {
|
||||
proxy_pass http://gateway:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@ -20,8 +20,6 @@
|
||||
"date-fns": "^2.30.0",
|
||||
"framer-motion": "^11.16.1",
|
||||
"lucide-react": "^0.294.0",
|
||||
"@supabase/supabase-js": "^2.98.0",
|
||||
"base64-arraybuffer": "^1.0.2",
|
||||
"zustand": "^4.4.7",
|
||||
"immer": "^10.0.3"
|
||||
},
|
||||
|
||||
52
src/api/auth.ts
Normal file
52
src/api/auth.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { http, setAccessToken } from './client';
|
||||
import type { Profile, Company, UserRole } from '../types';
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user: Profile;
|
||||
company: Company | null;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
display_name: string;
|
||||
company_name: string;
|
||||
phone: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: Profile;
|
||||
company: Company | null;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: async (data: LoginRequest) => {
|
||||
const res = await http.post<LoginResponse>('/auth/login', data);
|
||||
setAccessToken(res.data.access_token);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
register: (data: RegisterRequest) =>
|
||||
http.post<LoginResponse>('/auth/register', data).then(r => r.data),
|
||||
|
||||
logout: async () => {
|
||||
await http.post('/auth/logout');
|
||||
setAccessToken(null);
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const res = await http.post<{ access_token: string }>('/auth/refresh');
|
||||
setAccessToken(res.data.access_token);
|
||||
return res.data.access_token;
|
||||
},
|
||||
|
||||
me: () => http.get<MeResponse>('/auth/me').then(r => r.data),
|
||||
};
|
||||
119
src/api/client.ts
Normal file
119
src/api/client.ts
Normal file
@ -0,0 +1,119 @@
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
meta?: { page: number; page_size: number; total: number; has_more: boolean };
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
let accessToken: string | null = null;
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
export function setAccessToken(token: string | null) {
|
||||
accessToken = token;
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async function refreshToken(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const body: ApiResponse<{ access_token: string }> = await res.json();
|
||||
if (body.code === 0 && body.data.access_token) {
|
||||
accessToken = body.data.access_token;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
params?: Record<string, string | number | undefined>,
|
||||
): Promise<ApiResponse<T>> {
|
||||
let url = `${API_BASE}${path}`;
|
||||
|
||||
if (params) {
|
||||
const qs = Object.entries(params)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||
.join('&');
|
||||
if (qs) url += `?${qs}`;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
|
||||
let res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (res.status === 401 && accessToken) {
|
||||
if (!refreshPromise) refreshPromise = refreshToken();
|
||||
const ok = await refreshPromise;
|
||||
refreshPromise = null;
|
||||
|
||||
if (ok) {
|
||||
headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
credentials: 'include',
|
||||
});
|
||||
} else {
|
||||
accessToken = null;
|
||||
window.dispatchEvent(new CustomEvent('auth:expired'));
|
||||
}
|
||||
}
|
||||
|
||||
const json: ApiResponse<T> = await res.json();
|
||||
if (json.code !== 0) {
|
||||
throw new ApiError(json.code, json.message);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public code: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get: <T>(path: string, params?: Record<string, string | number | undefined>) =>
|
||||
request<T>('GET', path, undefined, params),
|
||||
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>('POST', path, body),
|
||||
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>('PUT', path, body),
|
||||
|
||||
patch: <T>(path: string, body?: unknown) =>
|
||||
request<T>('PATCH', path, body),
|
||||
|
||||
delete: <T>(path: string) =>
|
||||
request<T>('DELETE', path),
|
||||
};
|
||||
49
src/api/common.ts
Normal file
49
src/api/common.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { http } from './client';
|
||||
import type { Company, CompanyMember, Profile } from '../types';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface CompanyRelationship {
|
||||
id: string;
|
||||
company: Company;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export const commonApi = {
|
||||
getCompany: (id: string) =>
|
||||
http.get<Company>(`/common/companies/${id}`).then(r => r.data),
|
||||
|
||||
listRelationships: () =>
|
||||
http.get<CompanyRelationship[]>('/common/companies').then(r => r.data),
|
||||
|
||||
listMembers: () =>
|
||||
http.get<(CompanyMember & { profile: Profile })[]>('/common/members').then(r => r.data),
|
||||
|
||||
createMember: (data: { username: string; password: string; display_name: string; phone?: string }) =>
|
||||
http.post<CompanyMember>('/common/members', data).then(r => r.data),
|
||||
|
||||
deleteMember: (id: string) =>
|
||||
http.delete(`/common/members/${id}`),
|
||||
|
||||
listNotifications: () =>
|
||||
http.get<Notification[]>('/common/notifications').then(r => r.data),
|
||||
|
||||
markNotificationRead: (id: string) =>
|
||||
http.patch(`/common/notifications/${id}`, { is_read: true }),
|
||||
|
||||
createShareLink: (data: { type: string; target_id: string }) =>
|
||||
http.post<{ code: string }>('/common/share-links', data).then(r => r.data),
|
||||
|
||||
getShareLink: (code: string) =>
|
||||
http.get<unknown>(`/common/share-links/${code}`).then(r => r.data),
|
||||
|
||||
submitFeedback: (data: { content: string }) =>
|
||||
http.post('/common/feedback', data),
|
||||
};
|
||||
8
src/api/index.ts
Normal file
8
src/api/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export { http, setAccessToken, getAccessToken, ApiError } from './client';
|
||||
export type { ApiResponse, PaginationParams } from './client';
|
||||
export { authApi } from './auth';
|
||||
export { commonApi } from './common';
|
||||
export { purchaserApi } from './purchaser';
|
||||
export { textileApi } from './textile';
|
||||
export { washingApi } from './washing';
|
||||
export { wsClient } from './websocket';
|
||||
155
src/api/purchaser.ts
Normal file
155
src/api/purchaser.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { http } from './client';
|
||||
import type { PaginationParams, ApiResponse } from './client';
|
||||
import type {
|
||||
ProductionPlan,
|
||||
PlanFactory,
|
||||
YarnRatio,
|
||||
PlanProcessStep,
|
||||
Product,
|
||||
WashingPlan,
|
||||
Company,
|
||||
} from '../types';
|
||||
import type { ProductPriceHistory, ProductInRecord } from '../types';
|
||||
|
||||
// --- Plans ---
|
||||
|
||||
export interface PlanDetail extends ProductionPlan {
|
||||
process_steps: PlanProcessStep[];
|
||||
inventory_records: { time: string; quantity: number; rolls: number | null }[];
|
||||
price_history?: ProductPriceHistory[];
|
||||
yarn_ratios?: YarnRatio[];
|
||||
image_url?: string | null;
|
||||
warp_weight?: number | null;
|
||||
weft_weight?: number | null;
|
||||
total_weight?: number | null;
|
||||
}
|
||||
|
||||
export interface PlanListResponse {
|
||||
plans: PlanDetail[];
|
||||
factories: Company[];
|
||||
plan_factories: PlanFactory[];
|
||||
operator_names: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CreatePlanRequest {
|
||||
product_name: string;
|
||||
fabric_code: string;
|
||||
color_code: string;
|
||||
color: string;
|
||||
target_quantity: number;
|
||||
unit_price?: number;
|
||||
notes?: string;
|
||||
yarn_ratios?: { yarn_name: string; ratio: number; amount_per_meter?: number; yarn_type?: string }[];
|
||||
}
|
||||
|
||||
// --- Products ---
|
||||
|
||||
export interface InboundRequest {
|
||||
batch_no: string;
|
||||
rolls: number;
|
||||
meters: number;
|
||||
notes?: string;
|
||||
warehouse_id?: string;
|
||||
plan_code?: string;
|
||||
factory_id?: string;
|
||||
source?: 'self' | 'textile';
|
||||
price_per_meter?: number;
|
||||
}
|
||||
|
||||
export interface OutboundRequest {
|
||||
batch_no: string;
|
||||
rolls: number;
|
||||
meters: number;
|
||||
outbound_type: string;
|
||||
notes?: string;
|
||||
washing_plan_id?: string;
|
||||
recipient_company_id?: string;
|
||||
}
|
||||
|
||||
// --- Dashboard ---
|
||||
|
||||
export interface DashboardStats {
|
||||
total_plans: number;
|
||||
producing_plans: number;
|
||||
completed_plans: number;
|
||||
total_products: number;
|
||||
total_stock_meters: number;
|
||||
pending_payable: number;
|
||||
}
|
||||
|
||||
export const purchaserApi = {
|
||||
// Dashboard
|
||||
dashboardStats: () =>
|
||||
http.get<DashboardStats>('/purchaser/dashboard/stats').then(r => r.data),
|
||||
|
||||
// Plans
|
||||
listPlans: (params?: PaginationParams) =>
|
||||
http.get<PlanListResponse>('/purchaser/plans', params as Record<string, number>),
|
||||
|
||||
createPlan: (data: CreatePlanRequest) =>
|
||||
http.post<ProductionPlan>('/purchaser/plans', data).then(r => r.data),
|
||||
|
||||
getPlan: (id: string) =>
|
||||
http.get<PlanDetail>(`/purchaser/plans/${id}`).then(r => r.data),
|
||||
|
||||
updatePlan: (id: string, data: Partial<CreatePlanRequest>) =>
|
||||
http.put<ProductionPlan>(`/purchaser/plans/${id}`, data).then(r => r.data),
|
||||
|
||||
deletePlan: (id: string) =>
|
||||
http.delete(`/purchaser/plans/${id}`),
|
||||
|
||||
assignFactory: (planId: string, data: { factory_id: string; factory_type: string }) =>
|
||||
http.post<PlanFactory>(`/purchaser/plans/${planId}/factories`, data).then(r => r.data),
|
||||
|
||||
getPlanSteps: (planId: string) =>
|
||||
http.get<PlanProcessStep[]>(`/purchaser/plans/${planId}/steps`).then(r => r.data),
|
||||
|
||||
getPlanInventory: (planId: string) =>
|
||||
http.get<unknown[]>(`/purchaser/plans/${planId}/inventory`).then(r => r.data),
|
||||
|
||||
// Products
|
||||
listProducts: (params?: PaginationParams) =>
|
||||
http.get<Product[]>('/purchaser/products', params as Record<string, number>),
|
||||
|
||||
createProduct: (data: Record<string, unknown>) =>
|
||||
http.post<Product>('/purchaser/products', data).then(r => r.data),
|
||||
|
||||
updateProduct: (id: string, data: Record<string, unknown>) =>
|
||||
http.put<Product>(`/purchaser/products/${id}`, data).then(r => r.data),
|
||||
|
||||
deleteProduct: (id: string) =>
|
||||
http.delete(`/purchaser/products/${id}`),
|
||||
|
||||
inbound: (productId: string, data: InboundRequest) =>
|
||||
http.post<ProductInRecord>(`/purchaser/products/${productId}/inbound`, data).then(r => r.data),
|
||||
|
||||
outbound: (productId: string, data: OutboundRequest) =>
|
||||
http.post<ProductInRecord>(`/purchaser/products/${productId}/outbound`, data).then(r => r.data),
|
||||
|
||||
getRecords: (productId: string) =>
|
||||
http.get<ProductInRecord[]>(`/purchaser/products/${productId}/records`).then(r => r.data),
|
||||
|
||||
updatePrice: (productId: string, data: { price: number; reason?: string }) =>
|
||||
http.put(`/purchaser/products/${productId}/price`, data),
|
||||
|
||||
priceHistory: (productId: string) =>
|
||||
http.get<ProductPriceHistory[]>(`/purchaser/products/${productId}/price-history`).then(r => r.data),
|
||||
|
||||
// Factories
|
||||
listFactories: () =>
|
||||
http.get<Company[]>('/purchaser/factories').then(r => r.data),
|
||||
|
||||
// Accounts Payable
|
||||
listAccountsPayable: (params?: PaginationParams) =>
|
||||
http.get<unknown[]>('/purchaser/accounts-payable', params as Record<string, number>),
|
||||
|
||||
payAccountsPayable: (id: string, data: { amount: number; notes?: string }) =>
|
||||
http.post(`/purchaser/accounts-payable/${id}/pay`, data),
|
||||
|
||||
// Washing Plans
|
||||
createWashingPlan: (data: Partial<WashingPlan>) =>
|
||||
http.post<WashingPlan>('/purchaser/washing-plans', data).then(r => r.data),
|
||||
|
||||
listWashingPlans: (params?: PaginationParams) =>
|
||||
http.get<WashingPlan[]>('/purchaser/washing-plans', params as Record<string, number>),
|
||||
};
|
||||
74
src/api/textile.ts
Normal file
74
src/api/textile.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { http } from './client';
|
||||
import type { PaginationParams } from './client';
|
||||
import type {
|
||||
ProductionPlan,
|
||||
PlanProcessStep,
|
||||
YarnStock,
|
||||
InventoryRecord,
|
||||
Payment,
|
||||
} from '../types';
|
||||
|
||||
// --- Plans ---
|
||||
|
||||
export interface TextilePlanDetail extends ProductionPlan {
|
||||
process_steps: PlanProcessStep[];
|
||||
purchaser_name: string;
|
||||
}
|
||||
|
||||
// --- Dashboard ---
|
||||
|
||||
export interface TextileDashboardStats {
|
||||
total_plans: number;
|
||||
producing_plans: number;
|
||||
completed_plans: number;
|
||||
total_yarn_types: number;
|
||||
total_inventory_meters: number;
|
||||
pending_receivable: number;
|
||||
}
|
||||
|
||||
export const textileApi = {
|
||||
// Dashboard
|
||||
dashboardStats: () =>
|
||||
http.get<TextileDashboardStats>('/textile/dashboard/stats').then(r => r.data),
|
||||
|
||||
// Plans (read-only from textile perspective)
|
||||
listPlans: (params?: PaginationParams) =>
|
||||
http.get<TextilePlanDetail[]>('/textile/plans', params as Record<string, number>),
|
||||
|
||||
getPlan: (id: string) =>
|
||||
http.get<TextilePlanDetail>(`/textile/plans/${id}`).then(r => r.data),
|
||||
|
||||
// Process Steps
|
||||
completeStep: (planId: string, stepId: string, data?: { notes?: string; operator_name?: string }) =>
|
||||
http.post<PlanProcessStep>(`/textile/plans/${planId}/steps/${stepId}/complete`, data).then(r => r.data),
|
||||
|
||||
rejectStep: (planId: string, stepId: string, data: { reason: string }) =>
|
||||
http.post<PlanProcessStep>(`/textile/plans/${planId}/steps/${stepId}/reject`, data).then(r => r.data),
|
||||
|
||||
// Inventory
|
||||
inbound: (planId: string, data: { quantity: number; rolls?: number; notes?: string }) =>
|
||||
http.post<InventoryRecord>(`/textile/plans/${planId}/inventory`, data).then(r => r.data),
|
||||
|
||||
listInventory: (planId: string) =>
|
||||
http.get<InventoryRecord[]>(`/textile/plans/${planId}/inventory`).then(r => r.data),
|
||||
|
||||
// Yarn Stock
|
||||
listYarnStock: (params?: PaginationParams) =>
|
||||
http.get<YarnStock[]>('/textile/yarn-stock', params as Record<string, number>),
|
||||
|
||||
createYarnStock: (data: Partial<YarnStock>) =>
|
||||
http.post<YarnStock>('/textile/yarn-stock', data).then(r => r.data),
|
||||
|
||||
updateYarnStock: (id: string, data: Partial<YarnStock>) =>
|
||||
http.put<YarnStock>(`/textile/yarn-stock/${id}`, data).then(r => r.data),
|
||||
|
||||
addYarnRecord: (id: string, data: { change_type: string; quantity: number; notes?: string }) =>
|
||||
http.post(`/textile/yarn-stock/${id}/records`, data),
|
||||
|
||||
// Payments
|
||||
listPayments: (params?: PaginationParams) =>
|
||||
http.get<Payment[]>('/textile/payments', params as Record<string, number>),
|
||||
|
||||
confirmPayment: (id: string) =>
|
||||
http.post<Payment>(`/textile/payments/${id}/confirm`).then(r => r.data),
|
||||
};
|
||||
63
src/api/washing.ts
Normal file
63
src/api/washing.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { http } from './client';
|
||||
import type { PaginationParams } from './client';
|
||||
import type {
|
||||
WashingPlan,
|
||||
FinishedProduct,
|
||||
FinishedProductInventoryRecord,
|
||||
Payment,
|
||||
} from '../types';
|
||||
import type { WashingCompletionFormData } from '../types';
|
||||
|
||||
// --- Dashboard ---
|
||||
|
||||
export interface WashingDashboardStats {
|
||||
total_plans: number;
|
||||
processing_plans: number;
|
||||
completed_plans: number;
|
||||
total_finished_products: number;
|
||||
total_stock_meters: number;
|
||||
pending_receivable: number;
|
||||
}
|
||||
|
||||
export const washingApi = {
|
||||
// Dashboard
|
||||
dashboardStats: () =>
|
||||
http.get<WashingDashboardStats>('/washing/dashboard/stats').then(r => r.data),
|
||||
|
||||
// Plans
|
||||
listPlans: (params?: PaginationParams) =>
|
||||
http.get<WashingPlan[]>('/washing/plans', params as Record<string, number>),
|
||||
|
||||
listPendingPlans: () =>
|
||||
http.get<WashingPlan[]>('/washing/plans/pending').then(r => r.data),
|
||||
|
||||
listCompletedPlans: () =>
|
||||
http.get<WashingPlan[]>('/washing/plans/completed').then(r => r.data),
|
||||
|
||||
getPlan: (id: string) =>
|
||||
http.get<WashingPlan>(`/washing/plans/${id}`).then(r => r.data),
|
||||
|
||||
updatePlanStatus: (id: string, data: { status: string }) =>
|
||||
http.patch(`/washing/plans/${id}/status`, data),
|
||||
|
||||
// Completions
|
||||
completePlan: (planId: string, data: WashingCompletionFormData) =>
|
||||
http.post(`/washing/plans/${planId}/complete`, data),
|
||||
|
||||
// Finished Products
|
||||
listFinishedProducts: (params?: PaginationParams) =>
|
||||
http.get<FinishedProduct[]>('/washing/finished-products', params as Record<string, number>),
|
||||
|
||||
createFinishedProduct: (data: Partial<FinishedProduct>) =>
|
||||
http.post<FinishedProduct>('/washing/finished-products', data).then(r => r.data),
|
||||
|
||||
outboundFinishedProduct: (id: string, data: { rolls: number; meters: number; notes?: string }) =>
|
||||
http.post<FinishedProductInventoryRecord>(`/washing/finished-products/${id}/outbound`, data).then(r => r.data),
|
||||
|
||||
// Payments
|
||||
listPayments: (params?: PaginationParams) =>
|
||||
http.get<Payment[]>('/washing/payments', params as Record<string, number>),
|
||||
|
||||
confirmPayment: (id: string) =>
|
||||
http.post<Payment>(`/washing/payments/${id}/confirm`).then(r => r.data),
|
||||
};
|
||||
89
src/api/websocket.ts
Normal file
89
src/api/websocket.ts
Normal file
@ -0,0 +1,89 @@
|
||||
type ChangeCallback = () => void;
|
||||
|
||||
interface WSEvent {
|
||||
table: string;
|
||||
action: 'INSERT' | 'UPDATE' | 'DELETE';
|
||||
company_id: string;
|
||||
}
|
||||
|
||||
type WSRole = 'purchaser' | 'textile' | 'washing';
|
||||
|
||||
class WebSocketClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private listeners = new Map<string, Set<ChangeCallback>>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private role: WSRole | null = null;
|
||||
private connected = false;
|
||||
|
||||
connect(role: WSRole) {
|
||||
this.role = role;
|
||||
this.doConnect();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.connected = false;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(table: string, callback: ChangeCallback): () => void {
|
||||
if (!this.listeners.has(table)) {
|
||||
this.listeners.set(table, new Set());
|
||||
}
|
||||
this.listeners.get(table)!.add(callback);
|
||||
return () => {
|
||||
this.listeners.get(table)?.delete(callback);
|
||||
if (this.listeners.get(table)?.size === 0) {
|
||||
this.listeners.delete(table);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private doConnect() {
|
||||
if (!this.role) return;
|
||||
this.connected = true;
|
||||
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${protocol}//${location.host}/ws/v1/${this.role}`;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(url);
|
||||
|
||||
this.ws.onmessage = (e) => {
|
||||
try {
|
||||
const event: WSEvent = JSON.parse(e.data);
|
||||
this.listeners.get(event.table)?.forEach(cb => cb());
|
||||
this.listeners.get('*')?.forEach(cb => cb());
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
if (this.connected) this.scheduleReconnect();
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.ws?.close();
|
||||
};
|
||||
} catch {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimer) return;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (this.connected) this.doConnect();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
export const wsClient = new WebSocketClient();
|
||||
@ -14,7 +14,7 @@ import {
|
||||
Droplets
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { http } from '../api';
|
||||
|
||||
export type AccountRole = 'purchaser' | 'textile' | 'washing';
|
||||
|
||||
@ -79,34 +79,30 @@ export function AccountProfileCard({
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return;
|
||||
if (!auth.user) return;
|
||||
|
||||
try {
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
// TODO: 头像上传接口待后端实现
|
||||
const formDataObj = new FormData();
|
||||
formDataObj.append('file', file);
|
||||
formDataObj.append('bucket', 'avatars');
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(fileName, arrayBuffer, { contentType: file.type, upsert: true });
|
||||
const res = await fetch('/api/v1/common/upload', {
|
||||
method: 'POST',
|
||||
body: formDataObj,
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
alert('头像上传失败: ' + uploadError.message);
|
||||
if (!res.ok) {
|
||||
alert('头像上传失败');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage.from('avatars').getPublicUrl(fileName);
|
||||
const { error: updateError } = await supabase
|
||||
.from('profiles')
|
||||
.update({ image_url: publicUrl } as any)
|
||||
.eq('id', user.id);
|
||||
const json = await res.json();
|
||||
const publicUrl = json.data?.url || '';
|
||||
|
||||
await http.patch('/auth/me', { image_url: publicUrl });
|
||||
|
||||
if (updateError) {
|
||||
alert('头像更新失败: ' + updateError.message);
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('头像上传出错,请重试');
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AlertTriangle, X, Send, Bug, CheckCircle } from 'lucide-react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { http } from '../api';
|
||||
|
||||
interface CrashReport {
|
||||
error: Error;
|
||||
@ -40,19 +40,13 @@ export function CrashReporter() {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// 获取当前用户信息
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
// 提交崩溃报告
|
||||
await supabase.from('crash_reports').insert({
|
||||
await http.post('/common/crash-reports', {
|
||||
error_message: crash.error.message,
|
||||
error_stack: crash.error.stack,
|
||||
component_stack: crash.errorInfo.componentStack,
|
||||
user_description: userDescription.trim() || null,
|
||||
user_agent: crash.userAgent,
|
||||
url: crash.url,
|
||||
user_id: user?.id || null,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
setIsSubmitted(true);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Send, MessageSquare, Bug, Lightbulb, HelpCircle } from 'lucide-react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { commonApi } from '../api';
|
||||
|
||||
interface FeedbackModalProps {
|
||||
isOpen: boolean;
|
||||
@ -29,16 +29,8 @@ export function FeedbackModal({ isOpen, onClose }: FeedbackModalProps) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// 获取当前用户信息
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
// 提交反馈到数据库
|
||||
await supabase.from('user_feedback').insert({
|
||||
type,
|
||||
content: content.trim(),
|
||||
contact: contact.trim() || null,
|
||||
user_id: user?.id || null,
|
||||
status: 'pending',
|
||||
await commonApi.submitFeedback({
|
||||
content: `[${type}] ${content.trim()}${contact.trim() ? ` (联系方式: ${contact.trim()})` : ''}`,
|
||||
});
|
||||
|
||||
setIsSuccess(true);
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Camera, X, Upload, ImageIcon } from 'lucide-react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { decode } from 'base64-arraybuffer';
|
||||
import { http } from '../api';
|
||||
|
||||
export type UploadBucket = 'avatars' | 'product_images';
|
||||
|
||||
@ -69,44 +68,29 @@ export function ImageUploader({
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
// 读取文件为 base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const base64 = (event.target?.result as string).split(',')[1];
|
||||
const formDataObj = new FormData();
|
||||
formDataObj.append('file', file);
|
||||
formDataObj.append('bucket', bucket);
|
||||
formDataObj.append('path_prefix', pathPrefix);
|
||||
|
||||
// 生成唯一文件名
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = bucket === 'avatars'
|
||||
? `${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`
|
||||
: `${pathPrefix}/${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`;
|
||||
|
||||
// 上传图片到 Supabase Storage
|
||||
const { error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.upload(fileName, decode(base64), {
|
||||
contentType: file.type,
|
||||
upsert: true
|
||||
// TODO: 文件上传接口待后端实现,当前使用 fetch 直接发送 FormData
|
||||
const res = await fetch('/api/v1/common/upload', {
|
||||
method: 'POST',
|
||||
body: formDataObj,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('上传失败:', error);
|
||||
alert('图片上传失败: ' + error.message);
|
||||
if (!res.ok) {
|
||||
alert('图片上传失败');
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取图片的公开 URL
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from(bucket)
|
||||
.getPublicUrl(fileName);
|
||||
|
||||
const json = await res.json();
|
||||
const publicUrl = json.data?.url || '';
|
||||
setPreviewUrl(publicUrl);
|
||||
onChange(publicUrl);
|
||||
setUploading(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
console.error('上传错误:', error);
|
||||
alert('图片上传失败');
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Bell, X, Check, CheckCheck, Clock, FileText, Package, CreditCard, AlertCircle, Edit3 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { commonApi } from '../api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface Notification {
|
||||
@ -52,23 +52,15 @@ export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps
|
||||
useEffect(() => {
|
||||
if (!auth.user) return;
|
||||
fetchNotifications();
|
||||
const channel = subscribeToNotifications();
|
||||
|
||||
return () => {
|
||||
channel.unsubscribe();
|
||||
};
|
||||
const interval = setInterval(fetchNotifications, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [auth.user]);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
const { data } = await supabase
|
||||
.from('notifications')
|
||||
.select('*, sender_company_id(name)')
|
||||
.eq('recipient_id', auth.user!.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
if (data) {
|
||||
const formatted = data.map(n => ({
|
||||
try {
|
||||
const data = await commonApi.listNotifications();
|
||||
const formatted = (data as any[]).map((n: any) => ({
|
||||
id: n.id,
|
||||
type: n.type as Notification['type'],
|
||||
title: n.title,
|
||||
@ -76,39 +68,17 @@ export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps
|
||||
is_read: n.is_read || false,
|
||||
created_at: n.created_at || '',
|
||||
plan_id: n.plan_id,
|
||||
sender_company_name: n.sender_company_id?.name
|
||||
sender_company_name: n.sender_company_name
|
||||
}));
|
||||
setNotifications(formatted);
|
||||
setUnreadCount(formatted.filter(n => !n.is_read).length);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const subscribeToNotifications = () => {
|
||||
const channel = supabase.channel('notifications');
|
||||
|
||||
channel
|
||||
.on('postgres_changes', {
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'notifications',
|
||||
filter: `recipient_id=eq.${auth.user!.id}`
|
||||
}, () => {
|
||||
fetchNotifications();
|
||||
})
|
||||
.subscribe((status) => {
|
||||
if (status === 'SUBSCRIBED') {
|
||||
console.log('Successfully subscribed to notifications');
|
||||
}
|
||||
});
|
||||
|
||||
return channel;
|
||||
};
|
||||
|
||||
const markAsRead = async (id: string) => {
|
||||
await supabase
|
||||
.from('notifications')
|
||||
.update({ is_read: true, read_at: new Date().toISOString() })
|
||||
.eq('id', id);
|
||||
await commonApi.markNotificationRead(id);
|
||||
|
||||
setNotifications(prev => prev.map(n =>
|
||||
n.id === id ? { ...n, is_read: true } : n
|
||||
@ -117,11 +87,8 @@ export function NotificationCenter({ theme = 'amber' }: NotificationCenterProps
|
||||
};
|
||||
|
||||
const markAllAsRead = async () => {
|
||||
await supabase
|
||||
.from('notifications')
|
||||
.update({ is_read: true, read_at: new Date().toISOString() })
|
||||
.eq('recipient_id', auth.user!.id)
|
||||
.eq('is_read', false);
|
||||
const unread = notifications.filter(n => !n.is_read);
|
||||
await Promise.all(unread.map(n => commonApi.markNotificationRead(n.id)));
|
||||
|
||||
setNotifications(prev => prev.map(n => ({ ...n, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, AlertTriangle, Package, Check, Loader2, Edit3, Search } from 'lucide-react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { http } from '../api';
|
||||
import type { YarnRatio, YarnStock } from '../types';
|
||||
|
||||
interface YarnAllocation {
|
||||
@ -54,13 +54,12 @@ export function YarnAllocationModal({
|
||||
|
||||
const fetchYarnStock = async () => {
|
||||
setLoading(true);
|
||||
const { data } = await supabase
|
||||
.from('yarn_stock')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.order('name');
|
||||
|
||||
setYarnStock(data || []);
|
||||
try {
|
||||
const res = await http.get<YarnStock[]>('/textile/yarn-stock', { company_id: companyId });
|
||||
setYarnStock(res.data || []);
|
||||
} catch {
|
||||
setYarnStock([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@ -186,40 +185,24 @@ export function YarnAllocationModal({
|
||||
setConfirming(true);
|
||||
|
||||
try {
|
||||
// 获取当前用户ID
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
const operatorId = user?.id;
|
||||
|
||||
if (isManualMode) {
|
||||
// 手动模式:在仓库中查找或创建纱线并扣除库存
|
||||
for (const alloc of allocations) {
|
||||
const manualName = alloc.manualName!;
|
||||
const manualQty = alloc.manualQuantity!;
|
||||
|
||||
// 查找是否已有同名纱线
|
||||
const existingYarn = yarnStock.find(y =>
|
||||
y.name.toLowerCase() === manualName.toLowerCase()
|
||||
);
|
||||
|
||||
if (existingYarn) {
|
||||
// 扣除现有纱线库存
|
||||
if (existingYarn.quantity >= manualQty) {
|
||||
const newQuantity = existingYarn.quantity - manualQty;
|
||||
await supabase
|
||||
.from('yarn_stock')
|
||||
.update({ quantity: newQuantity })
|
||||
.eq('id', existingYarn.id);
|
||||
await http.put(`/textile/yarn-stock/${existingYarn.id}`, { quantity: newQuantity });
|
||||
|
||||
// 记录出库
|
||||
await supabase.from('yarn_stock_records').insert({
|
||||
company_id: companyId,
|
||||
yarn_stock_id: existingYarn.id,
|
||||
plan_id: planId || null,
|
||||
record_type: 'outbound',
|
||||
await http.post(`/textile/yarn-stock/${existingYarn.id}/records`, {
|
||||
change_type: 'outbound',
|
||||
quantity: manualQty,
|
||||
unit: 'kg',
|
||||
notes: `采纱出库 - 计划: ${planId || '未知'}`,
|
||||
operator_id: operatorId
|
||||
});
|
||||
} else {
|
||||
alert(`纱线 "${manualName}" 库存不足,需要 ${manualQty}kg,库存 ${existingYarn.quantity}kg`);
|
||||
@ -227,54 +210,34 @@ export function YarnAllocationModal({
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 创建新纱线记录并扣除(负库存表示已使用但未入库)
|
||||
const { data: newYarn } = await supabase
|
||||
.from('yarn_stock')
|
||||
.insert({
|
||||
const res = await http.post<YarnStock>('/textile/yarn-stock', {
|
||||
company_id: companyId,
|
||||
name: manualName,
|
||||
quantity: -manualQty,
|
||||
unit: 'kg',
|
||||
min_stock: 0
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
});
|
||||
const newYarn = res.data;
|
||||
|
||||
// 记录出库
|
||||
if (newYarn) {
|
||||
await supabase.from('yarn_stock_records').insert({
|
||||
company_id: companyId,
|
||||
yarn_stock_id: newYarn.id,
|
||||
plan_id: planId || null,
|
||||
record_type: 'outbound',
|
||||
await http.post(`/textile/yarn-stock/${newYarn.id}/records`, {
|
||||
change_type: 'outbound',
|
||||
quantity: manualQty,
|
||||
unit: 'kg',
|
||||
notes: `采纱出库(新纱线) - 计划: ${planId || '未知'}`,
|
||||
operator_id: operatorId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 自动模式:扣除库存
|
||||
for (const alloc of allocations) {
|
||||
if (alloc.allocatedYarn && alloc.isSufficient) {
|
||||
const newQuantity = alloc.allocatedYarn.quantity - alloc.requiredQuantity;
|
||||
await supabase
|
||||
.from('yarn_stock')
|
||||
.update({ quantity: newQuantity })
|
||||
.eq('id', alloc.allocatedYarn.id);
|
||||
await http.put(`/textile/yarn-stock/${alloc.allocatedYarn.id}`, { quantity: newQuantity });
|
||||
|
||||
// 记录出库
|
||||
await supabase.from('yarn_stock_records').insert({
|
||||
company_id: companyId,
|
||||
yarn_stock_id: alloc.allocatedYarn.id,
|
||||
plan_id: planId || null,
|
||||
record_type: 'outbound',
|
||||
await http.post(`/textile/yarn-stock/${alloc.allocatedYarn.id}/records`, {
|
||||
change_type: 'outbound',
|
||||
quantity: alloc.requiredQuantity,
|
||||
unit: 'kg',
|
||||
notes: `采纱出库 - 计划: ${planId || '未知'}`,
|
||||
operator_id: operatorId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, AlertTriangle, Edit2, Trash2 } from 'lucide-react';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { purchaserApi } from '../../api';
|
||||
import type { PlanWithSteps } from '../../types';
|
||||
|
||||
interface PlanEditModalProps {
|
||||
@ -39,24 +39,20 @@ export function PlanEditModal({ plan, isOpen, onClose, onPlanUpdated }: PlanEdit
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('production_plans')
|
||||
.update({
|
||||
try {
|
||||
await purchaserApi.updatePlan(plan.id, {
|
||||
product_name: editForm.product_name,
|
||||
color: editForm.color,
|
||||
color_code: editForm.color,
|
||||
target_quantity: editForm.target_quantity,
|
||||
remark: editForm.remark || null,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', plan.id);
|
||||
notes: editForm.remark || undefined,
|
||||
});
|
||||
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (updateError) {
|
||||
setError('更新失败: ' + updateError.message);
|
||||
} else {
|
||||
onPlanUpdated?.();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setIsSubmitting(false);
|
||||
setError('更新失败: ' + (err.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
@ -69,25 +65,15 @@ export function PlanEditModal({ plan, isOpen, onClose, onPlanUpdated }: PlanEdit
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
// 先删除关联的 process_steps
|
||||
await supabase.from('plan_process_steps').delete().eq('plan_id', plan.id);
|
||||
|
||||
// 再删除 plan_factories 关联
|
||||
await supabase.from('plan_factories').delete().eq('plan_id', plan.id);
|
||||
|
||||
// 最后删除计划
|
||||
const { error: deleteError } = await supabase
|
||||
.from('production_plans')
|
||||
.delete()
|
||||
.eq('id', plan.id);
|
||||
try {
|
||||
await purchaserApi.deletePlan(plan.id);
|
||||
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (deleteError) {
|
||||
setError('删除失败: ' + deleteError.message);
|
||||
} else {
|
||||
onPlanUpdated?.();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setIsSubmitting(false);
|
||||
setError('删除失败: ' + (err.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -3,7 +3,8 @@ import { motion } from 'framer-motion';
|
||||
import { Share2, Copy, Check, X, AlertTriangle, Building2, Eye, EyeOff, Link2, Send } from 'lucide-react';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
import { encryptShareParams } from '../../utils/shareCrypto';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { http, commonApi } from '../../api';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
interface ShareModalProps {
|
||||
isOpen: boolean;
|
||||
@ -53,6 +54,7 @@ export function ShareModal({
|
||||
onDirectPush
|
||||
}: ShareModalProps) {
|
||||
const { isMobile } = useResponsive();
|
||||
const { auth } = useAuth();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [recipientCompany, setRecipientCompany] = useState<string>('');
|
||||
@ -77,71 +79,48 @@ export function ShareModal({
|
||||
|
||||
setIsCheckingRelationship(true);
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
if (!auth.user) {
|
||||
setIsCheckingRelationship(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前用户的公司信息
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('company_id')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
try {
|
||||
if (auth.company) {
|
||||
setRecipientCompany(auth.company.name);
|
||||
|
||||
if (profile?.company_id) {
|
||||
// 获取公司名称
|
||||
const { data: company } = await supabase
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', profile.company_id)
|
||||
.single();
|
||||
const res = await http.get<{ has_relationship: boolean }>('/common/companies/check-relationship', {
|
||||
factory_id: factoryId,
|
||||
});
|
||||
setHasRelationship(res.data.has_relationship);
|
||||
|
||||
if (company) {
|
||||
setRecipientCompany(company.name);
|
||||
}
|
||||
|
||||
// 检查是否已有合作关系
|
||||
const { data: relationship } = await supabase
|
||||
.from('company_relationships')
|
||||
.select('*')
|
||||
.eq('purchaser_company_id', profile.company_id)
|
||||
.eq('factory_company_id', factoryId)
|
||||
.eq('status', 'active')
|
||||
.maybeSingle();
|
||||
|
||||
setHasRelationship(!!relationship);
|
||||
|
||||
// 如果没有合作关系,检查是否已有待处理的分享链接
|
||||
if (!relationship) {
|
||||
const { data: link } = await supabase
|
||||
.from('share_links')
|
||||
.select('*')
|
||||
.eq('plan_id', planId)
|
||||
.eq('factory_type', factoryType)
|
||||
.in('status', ['pending', 'clicked'])
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (link) {
|
||||
// 检查是否过期
|
||||
if (!res.data.has_relationship) {
|
||||
try {
|
||||
const linkRes = await http.get<ShareLinkStatus | null>('/common/share-links/active', {
|
||||
plan_id: planId,
|
||||
factory_type: factoryType,
|
||||
});
|
||||
if (linkRes.data) {
|
||||
const link = linkRes.data;
|
||||
const expiresAt = link.expires_at ? new Date(link.expires_at) : null;
|
||||
if (expiresAt && expiresAt > new Date()) {
|
||||
setExistingLink(link as ShareLinkStatus);
|
||||
setExistingLink(link);
|
||||
setShortCode(link.short_code);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// no existing link
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setHasRelationship(false);
|
||||
}
|
||||
|
||||
setIsCheckingRelationship(false);
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
// 清理
|
||||
return () => {
|
||||
shortLinkGenerated.current = false;
|
||||
setShortCode('');
|
||||
@ -157,64 +136,41 @@ export function ShareModal({
|
||||
|
||||
setIsGeneratingLink(true);
|
||||
|
||||
const baseUrl = window.location.origin + window.location.pathname;
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let code = '';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
try {
|
||||
const res = await commonApi.createShareLink({
|
||||
type: factoryType,
|
||||
target_id: planId,
|
||||
});
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
// 取消该计划之前未处理的链接
|
||||
await supabase
|
||||
.from('share_links')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('plan_id', planId)
|
||||
.eq('status', 'pending');
|
||||
|
||||
// 创建新链接(30分钟过期)
|
||||
const { data, error } = await supabase.from('share_links').insert({
|
||||
short_code: code,
|
||||
plan_id: planId,
|
||||
factory_type: factoryType,
|
||||
hide_sensitive: hideSensitive,
|
||||
created_by: user.id,
|
||||
status: 'pending',
|
||||
expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString() // 30分钟
|
||||
}).select().single();
|
||||
|
||||
if (!error && data) {
|
||||
setShortCode(code);
|
||||
setExistingLink(data as ShareLinkStatus);
|
||||
if (res.code) {
|
||||
setShortCode(res.code);
|
||||
} else {
|
||||
setError('生成链接失败,请重试');
|
||||
}
|
||||
} catch {
|
||||
setError('生成链接失败,请重试');
|
||||
}
|
||||
|
||||
setIsGeneratingLink(false);
|
||||
}, [planId, factoryType, hideSensitive, factoryId]);
|
||||
|
||||
// 直接推送计划(已有合作关系)
|
||||
const handleDirectPush = async () => {
|
||||
if (!factoryId || !onDirectPush) return;
|
||||
|
||||
setIsGeneratingLink(true);
|
||||
|
||||
// 直接创建 plan_factories 关联
|
||||
const { error } = await supabase.from('plan_factories').insert({
|
||||
plan_id: planId,
|
||||
try {
|
||||
await http.post(`/purchaser/plans/${planId}/factories`, {
|
||||
factory_id: factoryId,
|
||||
factory_type: factoryType
|
||||
factory_type: factoryType,
|
||||
});
|
||||
|
||||
if (!error) {
|
||||
setPushSuccess(true);
|
||||
setTimeout(() => {
|
||||
onDirectPush();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} else {
|
||||
} catch {
|
||||
setError('推送失败,请重试');
|
||||
}
|
||||
|
||||
@ -266,15 +222,14 @@ export function ShareModal({
|
||||
const checkLinkStatus = async () => {
|
||||
if (!existingLink) return;
|
||||
|
||||
const { data } = await supabase
|
||||
.from('share_links')
|
||||
.select('*')
|
||||
.eq('id', existingLink.id)
|
||||
.single();
|
||||
|
||||
try {
|
||||
const data = await commonApi.getShareLink(existingLink.short_code || existingLink.id);
|
||||
if (data) {
|
||||
setExistingLink(data as ShareLinkStatus);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Package, AlertCircle, Building2, Tag } from 'lucide-react';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { http, commonApi } from '../../api';
|
||||
import { purchaserApi } from '../../api';
|
||||
import type { ProductWithInventory, ProductInRecord, Company } from '../../types';
|
||||
|
||||
interface OutboundFormProps {
|
||||
@ -49,26 +50,21 @@ export function OutboundForm({
|
||||
const fetchInboundBatches = async () => {
|
||||
if (!formData.product_id) return;
|
||||
setLoadingBatches(true);
|
||||
const { data } = await supabase
|
||||
.from('product_inventory_records')
|
||||
.select('*')
|
||||
.eq('product_id', formData.product_id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (data) {
|
||||
setInboundBatches(data);
|
||||
try {
|
||||
const data = await purchaserApi.getRecords(formData.product_id);
|
||||
setInboundBatches(data || []);
|
||||
} catch {
|
||||
setInboundBatches([]);
|
||||
}
|
||||
setLoadingBatches(false);
|
||||
};
|
||||
|
||||
const fetchCompanies = async () => {
|
||||
const { data } = await supabase
|
||||
.from('companies')
|
||||
.select('*')
|
||||
.order('name', { ascending: true });
|
||||
|
||||
if (data) {
|
||||
setCompanies(data);
|
||||
try {
|
||||
const data = await purchaserApi.listFactories();
|
||||
setCompanies(data || []);
|
||||
} catch {
|
||||
setCompanies([]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -2,8 +2,7 @@ import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Package, Tag, Hash, Palette, DollarSign, Layers, Plus, Minus, Image as ImageIcon, Upload, Trash2, Search } from 'lucide-react';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { decode } from 'base64-arraybuffer';
|
||||
import { http } from '../../api';
|
||||
import type { Product, ProductYarnRatio } from '../../types';
|
||||
|
||||
interface ProductFormData {
|
||||
@ -138,22 +137,15 @@ export function ProductForm({
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
setShowProductSuggestions(true); // 立即显示建议框(带加载状态)
|
||||
setShowProductSuggestions(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('id, product_name, total_weight, warp_weight, weft_weight, color, fabric_code, color_code, production_price, image_url')
|
||||
.eq('company_id', companyId)
|
||||
.ilike('product_name', `%${query}%`)
|
||||
.limit(10);
|
||||
const res = await http.get<ProductSuggestion[]>('/purchaser/products', {
|
||||
search: query,
|
||||
page_size: 10,
|
||||
});
|
||||
|
||||
if (!error && data) {
|
||||
setProductSuggestions(data as ProductSuggestion[]);
|
||||
} else {
|
||||
setProductSuggestions([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('搜索产品失败:', err);
|
||||
setProductSuggestions(res.data || []);
|
||||
} catch {
|
||||
setProductSuggestions([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
@ -168,22 +160,18 @@ export function ProductForm({
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('total_weight')
|
||||
.eq('company_id', companyId)
|
||||
.eq('product_name', productName)
|
||||
.not('total_weight', 'is', null)
|
||||
.limit(20);
|
||||
const res = await http.get<{ total_weight: number | null }[]>('/purchaser/products', {
|
||||
search: productName,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
if (!error && data) {
|
||||
// 提取唯一的克重值,过滤掉 null 值
|
||||
const weights = data.map(p => p.total_weight).filter((w): w is number => w !== null && w > 0);
|
||||
const weights = (res.data || [])
|
||||
.map(p => p.total_weight)
|
||||
.filter((w): w is number => w !== null && w > 0);
|
||||
const uniqueWeights = Array.from(new Set(weights));
|
||||
setWeightSuggestions(uniqueWeights.sort((a, b) => a - b));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('搜索克重失败:', err);
|
||||
} catch {
|
||||
setWeightSuggestions([]);
|
||||
}
|
||||
}, [companyId]);
|
||||
|
||||
@ -214,19 +202,16 @@ export function ProductForm({
|
||||
if (!prefix) return '';
|
||||
|
||||
try {
|
||||
// 查询该公司所有以该前缀开头的产品码
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('fabric_code')
|
||||
.eq('company_id', companyId)
|
||||
.ilike('fabric_code', `${prefix}%`);
|
||||
const res = await http.get<{ fabric_code: string }[]>('/purchaser/products', {
|
||||
search: prefix,
|
||||
page_size: 100,
|
||||
});
|
||||
const data = res.data || [];
|
||||
|
||||
if (error || !data || data.length === 0) {
|
||||
// 如果没有匹配的产品,从1开始
|
||||
if (data.length === 0) {
|
||||
return `${prefix}1`;
|
||||
}
|
||||
|
||||
// 提取所有产品码中的数字,找出最大值
|
||||
let maxNum = 0;
|
||||
data.forEach(item => {
|
||||
const code = item.fabric_code;
|
||||
@ -239,10 +224,8 @@ export function ProductForm({
|
||||
}
|
||||
});
|
||||
|
||||
// 返回前缀 + (最大数字 + 1)
|
||||
return `${prefix}${maxNum + 1}`;
|
||||
} catch (err) {
|
||||
console.error('生成产品码失败:', err);
|
||||
} catch {
|
||||
return `${prefix}1`;
|
||||
}
|
||||
};
|
||||
@ -254,20 +237,16 @@ export function ProductForm({
|
||||
totalWeight: number | null
|
||||
): Promise<string> => {
|
||||
try {
|
||||
// 查询该产品名称-克重组合下的所有色号(色号在产品名称-克重下连续递增)
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('color_code')
|
||||
.eq('company_id', companyId)
|
||||
.eq('product_name', productName)
|
||||
.eq('total_weight', totalWeight || 0);
|
||||
const res = await http.get<{ color_code: string }[]>('/purchaser/products', {
|
||||
search: productName,
|
||||
page_size: 100,
|
||||
});
|
||||
const data = res.data || [];
|
||||
|
||||
if (error || !data || data.length === 0) {
|
||||
// 如果没有匹配的产品,从01开始
|
||||
if (data.length === 0) {
|
||||
return '01';
|
||||
}
|
||||
|
||||
// 提取所有色号中的数字,找出最大值
|
||||
let maxNum = 0;
|
||||
data.forEach(item => {
|
||||
const code = item.color_code;
|
||||
@ -277,11 +256,9 @@ export function ProductForm({
|
||||
}
|
||||
});
|
||||
|
||||
// 返回最大数字 + 1,保持两位数格式
|
||||
const nextNum = maxNum + 1;
|
||||
return nextNum < 10 ? `0${nextNum}` : String(nextNum);
|
||||
} catch (err) {
|
||||
console.error('生成色号失败:', err);
|
||||
} catch {
|
||||
return '01';
|
||||
}
|
||||
};
|
||||
@ -402,42 +379,29 @@ export function ProductForm({
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
// 读取文件为 base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const base64 = (event.target?.result as string).split(',')[1];
|
||||
// TODO: 文件上传接口待后端实现
|
||||
const formDataObj = new FormData();
|
||||
formDataObj.append('file', file);
|
||||
formDataObj.append('bucket', 'product_images');
|
||||
formDataObj.append('path_prefix', 'products');
|
||||
|
||||
// 生成唯一文件名
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`;
|
||||
const filePath = `products/${fileName}`;
|
||||
|
||||
// 上传图片到 Supabase Storage
|
||||
const { data, error } = await supabase.storage
|
||||
.from('product_images')
|
||||
.upload(filePath, decode(base64), {
|
||||
contentType: file.type,
|
||||
const res = await fetch('/api/v1/common/upload', {
|
||||
method: 'POST',
|
||||
body: formDataObj,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('上传失败:', error);
|
||||
alert('图片上传失败: ' + error.message);
|
||||
if (!res.ok) {
|
||||
alert('图片上传失败');
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取图片的公开 URL
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('product_images')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
const json = await res.json();
|
||||
const publicUrl = json.data?.url || '';
|
||||
setPreviewUrl(publicUrl);
|
||||
setFormData(prev => ({ ...prev, image_url: publicUrl }));
|
||||
setUploading(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
console.error('上传错误:', error);
|
||||
alert('图片上传失败');
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { authApi } from '../api';
|
||||
import { setAccessToken } from '../api/client';
|
||||
import { wsClient } from '../api/websocket';
|
||||
import type { Profile, Company, UserRole, AuthState } from '../types';
|
||||
import { encryptPhone, decryptPhone } from '../utils/crypto';
|
||||
|
||||
interface RegisterData {
|
||||
username: string;
|
||||
@ -32,195 +33,92 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 初始化:检查已有 session
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session?.user) {
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', session.user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (profile) {
|
||||
let company: Company | null = null;
|
||||
if (profile.company_id) {
|
||||
const { data: comp } = await supabase
|
||||
.from('companies')
|
||||
.select('*')
|
||||
.eq('id', profile.company_id)
|
||||
.maybeSingle();
|
||||
company = comp;
|
||||
}
|
||||
try {
|
||||
await authApi.refresh();
|
||||
const { user, company } = await authApi.me();
|
||||
setAuth({
|
||||
isLoggedIn: true,
|
||||
user: profile,
|
||||
user,
|
||||
company,
|
||||
currentRole: company?.role || null
|
||||
});
|
||||
if (company?.role) {
|
||||
wsClient.connect(company.role);
|
||||
}
|
||||
} catch {
|
||||
// not logged in
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
initAuth();
|
||||
|
||||
// 监听 auth 状态变化
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
if (event === 'SIGNED_IN' && session?.user) {
|
||||
setTimeout(async () => {
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', session.user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (profile) {
|
||||
let company: Company | null = null;
|
||||
if (profile.company_id) {
|
||||
const { data: comp } = await supabase
|
||||
.from('companies')
|
||||
.select('*')
|
||||
.eq('id', profile.company_id)
|
||||
.maybeSingle();
|
||||
company = comp;
|
||||
}
|
||||
setAuth({
|
||||
isLoggedIn: true,
|
||||
user: profile,
|
||||
company,
|
||||
currentRole: company?.role || null
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
} else if (event === 'SIGNED_OUT') {
|
||||
const handleExpired = () => {
|
||||
wsClient.disconnect();
|
||||
setAuth({
|
||||
isLoggedIn: false,
|
||||
user: null,
|
||||
company: null,
|
||||
currentRole: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
};
|
||||
window.addEventListener('auth:expired', handleExpired);
|
||||
return () => window.removeEventListener('auth:expired', handleExpired);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string): Promise<boolean> => {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email: `${username}@meoo.local`,
|
||||
password,
|
||||
try {
|
||||
const { user, company } = await authApi.login({ username, password });
|
||||
setAuth({
|
||||
isLoggedIn: true,
|
||||
user,
|
||||
company,
|
||||
currentRole: company?.role || null
|
||||
});
|
||||
if (error) {
|
||||
console.error('登录失败:', error.message);
|
||||
return false;
|
||||
if (company?.role) {
|
||||
wsClient.connect(company.role);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (data: RegisterData): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// 1. 创建公司
|
||||
const { data: companyData, error: companyError } = await supabase
|
||||
.from('companies')
|
||||
.insert({
|
||||
name: data.companyName,
|
||||
role: data.role,
|
||||
contact_phone: data.phone
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (companyError || !companyData) {
|
||||
console.error('创建公司失败:', companyError?.message);
|
||||
return { success: false, error: '创建公司失败,请稍后重试' };
|
||||
}
|
||||
|
||||
// 2. 注册用户
|
||||
const { data: authData, error: authError } = await supabase.auth.signUp({
|
||||
email: `${data.username}@meoo.local`,
|
||||
password: data.password,
|
||||
});
|
||||
|
||||
if (authError || !authData.user) {
|
||||
console.error('注册失败:', authError?.message);
|
||||
// 回滚:删除创建的公司
|
||||
await supabase.from('companies').delete().eq('id', companyData.id);
|
||||
|
||||
// 根据错误类型返回具体错误信息
|
||||
if (authError?.message?.includes('already registered') || authError?.message?.includes('already exists')) {
|
||||
return { success: false, error: '用户名已被注册,请更换用户名' };
|
||||
}
|
||||
if (authError?.message?.includes('password')) {
|
||||
return { success: false, error: '密码不符合要求,请使用至少6位字符' };
|
||||
}
|
||||
return { success: false, error: '注册失败:' + (authError?.message || '请检查信息后重试') };
|
||||
}
|
||||
|
||||
// 3. 创建用户资料(手机号加密存储)
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.insert({
|
||||
id: authData.user.id,
|
||||
const res = await authApi.register({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
display_name: data.displayName,
|
||||
phone: encryptPhone(data.phone),
|
||||
company_id: companyData.id,
|
||||
is_master: true
|
||||
company_name: data.companyName,
|
||||
phone: data.phone,
|
||||
role: data.role,
|
||||
});
|
||||
|
||||
if (profileError) {
|
||||
console.error('创建用户资料失败:', profileError.message);
|
||||
// 回滚:删除创建的公司
|
||||
await supabase.from('companies').delete().eq('id', companyData.id);
|
||||
|
||||
if (profileError.message?.includes('unique constraint') || profileError.message?.includes('duplicate')) {
|
||||
return { success: false, error: '用户名已被使用,请更换用户名' };
|
||||
setAccessToken(res.access_token);
|
||||
setAuth({
|
||||
isLoggedIn: true,
|
||||
user: res.user,
|
||||
company: res.company,
|
||||
currentRole: res.company?.role || null
|
||||
});
|
||||
if (res.company?.role) {
|
||||
wsClient.connect(res.company.role);
|
||||
}
|
||||
return { success: false, error: '创建用户资料失败,请稍后重试' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('注册过程出错:', err);
|
||||
return { success: false, error: '注册过程出错:' + (err?.message || '请稍后重试') };
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '注册失败,请稍后重试';
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
// 清除所有 cookie
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i];
|
||||
const eqPos = cookie.indexOf('=');
|
||||
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
|
||||
// 设置过期时间为过去,删除 cookie
|
||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname};`;
|
||||
}
|
||||
|
||||
// 清除 localStorage 和 sessionStorage 中的 Supabase 相关数据
|
||||
const storageKeys = Object.keys(localStorage);
|
||||
for (const key of storageKeys) {
|
||||
if (key.startsWith('sb-') || key.includes('supabase')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
const sessionKeys = Object.keys(sessionStorage);
|
||||
for (const key of sessionKeys) {
|
||||
if (key.startsWith('sb-') || key.includes('supabase')) {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除免密登录和记住密码相关存储
|
||||
try { await authApi.logout(); } catch { /* ignore */ }
|
||||
wsClient.disconnect();
|
||||
localStorage.removeItem('saved_username');
|
||||
localStorage.removeItem('saved_password');
|
||||
localStorage.removeItem('auto_login');
|
||||
localStorage.removeItem('auto_login_expiry');
|
||||
|
||||
await supabase.auth.signOut();
|
||||
setAuth({
|
||||
isLoggedIn: false,
|
||||
user: null,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { purchaserApi, textileApi, washingApi } from '../api';
|
||||
import type { ProductionPlan, WashingPlan } from '../types';
|
||||
|
||||
export type DashboardRole = 'purchaser' | 'textile' | 'washing';
|
||||
@ -23,10 +23,6 @@ interface DashboardDataState<T> {
|
||||
rejectedPlanIds: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 Dashboard 数据获取 Hook
|
||||
* 根据角色自动选择正确的数据源和过滤逻辑
|
||||
*/
|
||||
export function useDashboardData<T = ProductionPlan | WashingPlan>({
|
||||
role,
|
||||
companyId
|
||||
@ -39,134 +35,61 @@ export function useDashboardData<T = ProductionPlan | WashingPlan>({
|
||||
rejectedPlanIds: new Set()
|
||||
});
|
||||
|
||||
// 缓存引用,避免重复请求
|
||||
const cacheRef = useRef<{
|
||||
data: DashboardDataState<T> | null;
|
||||
timestamp: number;
|
||||
}>({ data: null, timestamp: 0 });
|
||||
|
||||
const CACHE_DURATION = 5000; // 5秒缓存
|
||||
const CACHE_DURATION = 5000;
|
||||
|
||||
// 计算统计数据
|
||||
const calculateStats = useCallback((plans: T[], roleType: DashboardRole): DashboardStats => {
|
||||
const all = plans.length;
|
||||
let producing = 0;
|
||||
let completed = 0;
|
||||
|
||||
if (roleType === 'washing') {
|
||||
// 水洗厂使用 processing 状态
|
||||
producing = (plans as WashingPlan[]).filter(p => p.status === 'processing').length;
|
||||
completed = (plans as WashingPlan[]).filter(p => p.status === 'completed').length;
|
||||
} else {
|
||||
// 采购商和纺织厂使用 producing 状态
|
||||
producing = (plans as ProductionPlan[]).filter(p => p.status === 'producing').length;
|
||||
completed = (plans as ProductionPlan[]).filter(p => p.status === 'completed').length;
|
||||
}
|
||||
|
||||
return { all, producing, completed };
|
||||
const fetchPurchaserData = useCallback(async () => {
|
||||
const [statsData, plansRes] = await Promise.all([
|
||||
purchaserApi.dashboardStats(),
|
||||
purchaserApi.listPlans(),
|
||||
]);
|
||||
const plans = plansRes.data.plans as unknown as T[];
|
||||
const stats: DashboardStats = {
|
||||
all: statsData.total_plans,
|
||||
producing: statsData.producing_plans,
|
||||
completed: statsData.completed_plans,
|
||||
};
|
||||
return { plans, stats, rejectedPlanIds: new Set<string>() };
|
||||
}, []);
|
||||
|
||||
// 获取采购商数据
|
||||
const fetchPurchaserData = useCallback(async () => {
|
||||
if (!companyId) return;
|
||||
|
||||
const { data: planData, error } = await supabase
|
||||
.from('production_plans')
|
||||
.select('*')
|
||||
.eq('purchaser_id', companyId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// 获取流程步骤,检测 rejected 状态
|
||||
const planIds = (planData || []).map(p => p.id);
|
||||
const { data: stepsData } = await supabase
|
||||
.from('plan_process_steps')
|
||||
.select('plan_id, status')
|
||||
.in('plan_id', planIds);
|
||||
|
||||
const rejectedIds = new Set<string>();
|
||||
(stepsData || []).forEach(step => {
|
||||
if (step.status === 'rejected') {
|
||||
rejectedIds.add(step.plan_id);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredPlans = (planData || []).filter(p => !rejectedIds.has(p.id)) as T[];
|
||||
const stats = calculateStats(filteredPlans, 'purchaser');
|
||||
|
||||
return { plans: filteredPlans, stats, rejectedPlanIds: rejectedIds };
|
||||
}, [companyId, calculateStats]);
|
||||
|
||||
// 获取纺织厂数据
|
||||
const fetchTextileData = useCallback(async () => {
|
||||
if (!companyId) return;
|
||||
|
||||
// 先获取关联的计划ID
|
||||
const { data: planFactories } = await supabase
|
||||
.from('plan_factories')
|
||||
.select('plan_id')
|
||||
.eq('factory_id', companyId)
|
||||
.eq('factory_type', 'textile');
|
||||
|
||||
if (!planFactories || planFactories.length === 0) {
|
||||
return { plans: [] as T[], stats: { all: 0, producing: 0, completed: 0 }, rejectedPlanIds: new Set<string>() };
|
||||
}
|
||||
|
||||
const planIds = planFactories.map(pf => pf.plan_id);
|
||||
const { data: planData, error } = await supabase
|
||||
.from('production_plans')
|
||||
.select('*')
|
||||
.in('id', planIds)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// 获取流程步骤,检测 rejected 状态
|
||||
const { data: stepsData } = await supabase
|
||||
.from('plan_process_steps')
|
||||
.select('plan_id, status')
|
||||
.in('plan_id', planIds);
|
||||
|
||||
const rejectedIds = new Set<string>();
|
||||
(stepsData || []).forEach(step => {
|
||||
if (step.status === 'rejected') {
|
||||
rejectedIds.add(step.plan_id);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredPlans = (planData || []).filter(p => !rejectedIds.has(p.id)) as T[];
|
||||
const stats = calculateStats(filteredPlans, 'textile');
|
||||
|
||||
return { plans: filteredPlans, stats, rejectedPlanIds: rejectedIds };
|
||||
}, [companyId, calculateStats]);
|
||||
|
||||
// 获取水洗厂数据
|
||||
const fetchWashingData = useCallback(async () => {
|
||||
if (!companyId) return;
|
||||
|
||||
const { data: washingPlans, error } = await supabase
|
||||
.from('washing_plans')
|
||||
.select('*')
|
||||
.eq('washing_factory_id', companyId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const plans = (washingPlans || []) as T[];
|
||||
const stats = calculateStats(plans, 'washing');
|
||||
|
||||
const [statsData, plansRes] = await Promise.all([
|
||||
textileApi.dashboardStats(),
|
||||
textileApi.listPlans(),
|
||||
]);
|
||||
const plans = plansRes.data as unknown as T[];
|
||||
const stats: DashboardStats = {
|
||||
all: statsData.total_plans,
|
||||
producing: statsData.producing_plans,
|
||||
completed: statsData.completed_plans,
|
||||
};
|
||||
return { plans, stats, rejectedPlanIds: new Set<string>() };
|
||||
}, [companyId, calculateStats]);
|
||||
}, []);
|
||||
|
||||
const fetchWashingData = useCallback(async () => {
|
||||
const [statsData, plansRes] = await Promise.all([
|
||||
washingApi.dashboardStats(),
|
||||
washingApi.listPlans(),
|
||||
]);
|
||||
const plans = plansRes.data as unknown as T[];
|
||||
const stats: DashboardStats = {
|
||||
all: statsData.total_plans,
|
||||
producing: statsData.processing_plans,
|
||||
completed: statsData.completed_plans,
|
||||
};
|
||||
return { plans, stats, rejectedPlanIds: new Set<string>() };
|
||||
}, []);
|
||||
|
||||
// 主数据获取函数
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!companyId) {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
const now = Date.now();
|
||||
if (cacheRef.current.data && (now - cacheRef.current.timestamp) < CACHE_DURATION) {
|
||||
setState(cacheRef.current.data);
|
||||
@ -174,22 +97,22 @@ export function useDashboardData<T = ProductionPlan | WashingPlan>({
|
||||
}
|
||||
|
||||
try {
|
||||
let result: DashboardDataState<T> | undefined;
|
||||
let result: { plans: T[]; stats: DashboardStats; rejectedPlanIds: Set<string> } | undefined;
|
||||
|
||||
switch (role) {
|
||||
case 'purchaser':
|
||||
result = await fetchPurchaserData() as DashboardDataState<T> | undefined;
|
||||
result = await fetchPurchaserData();
|
||||
break;
|
||||
case 'textile':
|
||||
result = await fetchTextileData() as DashboardDataState<T> | undefined;
|
||||
result = await fetchTextileData();
|
||||
break;
|
||||
case 'washing':
|
||||
result = await fetchWashingData() as DashboardDataState<T> | undefined;
|
||||
result = await fetchWashingData();
|
||||
break;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
const newState = {
|
||||
const newState: DashboardDataState<T> = {
|
||||
plans: result.plans,
|
||||
stats: result.stats,
|
||||
loading: false,
|
||||
@ -208,12 +131,10 @@ export function useDashboardData<T = ProductionPlan | WashingPlan>({
|
||||
}
|
||||
}, [companyId, role, fetchPurchaserData, fetchTextileData, fetchWashingData]);
|
||||
|
||||
// 初始加载和 companyId 变化时重新获取
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 提供手动刷新方法
|
||||
const refresh = useCallback(() => {
|
||||
cacheRef.current = { data: null, timestamp: 0 };
|
||||
fetchData();
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { purchaserApi } from '../api';
|
||||
import { CACHE_CONFIG } from '../config/app';
|
||||
import { useInventoryRealtime } from './useRealtime';
|
||||
import type { Tables } from '../supabase/types';
|
||||
import type {
|
||||
Product,
|
||||
ProductWithInventory,
|
||||
ProductYarnRatio,
|
||||
FabricBatch,
|
||||
ProductInRecord
|
||||
ProductInRecord,
|
||||
FactoryInventory,
|
||||
} from '../types';
|
||||
|
||||
interface UseInventoryDataOptions {
|
||||
@ -24,32 +24,6 @@ interface InventoryDataState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
// Supabase Row 类型别名
|
||||
type InventoryRecord = Tables<'inventory_records'>;
|
||||
type ProductInventoryRecord = Tables<'product_inventory_records'>;
|
||||
type ProductOutboundRecord = Tables<'product_outbound_records'>;
|
||||
|
||||
// 部分字段查询的局部类型(避免使用 any)
|
||||
interface PlanSummary {
|
||||
id: string;
|
||||
fabric_code: string;
|
||||
completed_quantity: number;
|
||||
}
|
||||
|
||||
interface PlanFactoryRef {
|
||||
plan_id: string;
|
||||
factory_id: string;
|
||||
}
|
||||
|
||||
interface CompanyRef {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化的库存数据获取 Hook
|
||||
* 使用缓存和防抖来减少不必要的请求
|
||||
*/
|
||||
export function useInventoryData({ companyId, activeType }: UseInventoryDataOptions) {
|
||||
const [state, setState] = useState<InventoryDataState>({
|
||||
products: [],
|
||||
@ -76,7 +50,6 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
|
||||
const cacheKey = `${companyId}-${activeType}`;
|
||||
const now = Date.now();
|
||||
|
||||
// 检查缓存
|
||||
if (
|
||||
cacheRef.current.data &&
|
||||
cacheRef.current.key === cacheKey &&
|
||||
@ -89,135 +62,86 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
|
||||
setState(prev => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
// 并行获取数据
|
||||
const [productsResult, batchesResult, outboundResult] = await Promise.all([
|
||||
// 获取产品列表
|
||||
supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: false }),
|
||||
// 获取入库记录
|
||||
supabase
|
||||
.from('product_inventory_records')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: false }),
|
||||
// 获取出库记录
|
||||
supabase
|
||||
.from('product_outbound_records')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
]);
|
||||
const productsRes = await purchaserApi.listProducts();
|
||||
const products: Product[] = productsRes.data;
|
||||
|
||||
if (productsResult.error) throw productsResult.error;
|
||||
if (batchesResult.error) throw batchesResult.error;
|
||||
if (outboundResult.error) throw outboundResult.error;
|
||||
const recordResults = await Promise.all(
|
||||
products.map(p =>
|
||||
purchaserApi.getRecords(p.id).catch(() => [] as ProductInRecord[])
|
||||
)
|
||||
);
|
||||
|
||||
const productsData = productsResult.data || [];
|
||||
const batchesData: ProductInventoryRecord[] = batchesResult.data || [];
|
||||
const outboundData: ProductOutboundRecord[] = outboundResult.data || [];
|
||||
const allBatches: FabricBatch[] = [];
|
||||
const updatedProducts: ProductWithInventory[] = products.map((p, i) => {
|
||||
const records = recordResults[i];
|
||||
const inRecords = records.filter(r => r.record_type !== 'out');
|
||||
const outRecords = records.filter(r => r.record_type === 'out');
|
||||
|
||||
// 获取纱线配比
|
||||
const productIds = productsData.map(p => p.id);
|
||||
let ratiosMap: Record<string, ProductYarnRatio[]> = {};
|
||||
const totalInRolls = inRecords.reduce((sum, r) => sum + (r.rolls || 0), 0);
|
||||
const totalInMeters = inRecords.reduce((sum, r) => sum + (r.meters || 0), 0);
|
||||
const totalOutRolls = outRecords.reduce((sum, r) => sum + (r.rolls || 0), 0);
|
||||
const totalOutMeters = outRecords.reduce((sum, r) => sum + (r.meters || 0), 0);
|
||||
|
||||
if (productIds.length > 0) {
|
||||
const { data: ratiosData, error: ratiosError } = await supabase
|
||||
.from('product_yarn_ratios')
|
||||
.select('*')
|
||||
.in('product_id', productIds);
|
||||
|
||||
if (ratiosError) throw ratiosError;
|
||||
|
||||
ratiosData?.forEach((r: ProductYarnRatio) => {
|
||||
if (!ratiosMap[r.product_id]) ratiosMap[r.product_id] = [];
|
||||
ratiosMap[r.product_id].push(r);
|
||||
});
|
||||
}
|
||||
|
||||
// 获取纺织厂入库数据
|
||||
const fabricCodes = productsData.map(p => p.fabric_code);
|
||||
const { data: plansData } = await supabase
|
||||
.from('production_plans')
|
||||
.select('id, fabric_code, completed_quantity')
|
||||
.eq('purchaser_id', companyId)
|
||||
.in('fabric_code', fabricCodes);
|
||||
|
||||
const typedPlansData: PlanSummary[] = plansData || [];
|
||||
const planIds = typedPlansData.map(p => p.id).filter(Boolean);
|
||||
|
||||
// 获取工厂信息
|
||||
const { data: planFactoriesData } = await supabase
|
||||
.from('plan_factories')
|
||||
.select('plan_id, factory_id')
|
||||
.in('plan_id', planIds)
|
||||
.eq('factory_type', 'textile');
|
||||
|
||||
const typedPlanFactories: PlanFactoryRef[] = planFactoriesData || [];
|
||||
const factoryIds = [...new Set(typedPlanFactories.map(pf => pf.factory_id).filter(Boolean))];
|
||||
const { data: factoriesData } = await supabase
|
||||
.from('companies')
|
||||
.select('id, name')
|
||||
.in('id', factoryIds);
|
||||
|
||||
const typedFactories: CompanyRef[] = factoriesData || [];
|
||||
|
||||
// 建立映射
|
||||
const planToFactoryName: Record<string, string> = {};
|
||||
typedPlanFactories.forEach((pf: PlanFactoryRef) => {
|
||||
const factory = typedFactories.find(f => f.id === pf.factory_id);
|
||||
if (factory) {
|
||||
planToFactoryName[pf.plan_id] = factory.name;
|
||||
const factoryInventories: Record<string, FactoryInventory> = {};
|
||||
inRecords
|
||||
.filter(r => r.source === 'textile' && r.factory_name)
|
||||
.forEach(r => {
|
||||
const key = r.factory_name!;
|
||||
if (!factoryInventories[key]) {
|
||||
factoryInventories[key] = {
|
||||
factory_id: r.factory_id || 'unknown',
|
||||
factory_name: key,
|
||||
rolls: 0,
|
||||
meters: 0,
|
||||
records: [],
|
||||
};
|
||||
}
|
||||
factoryInventories[key].rolls += r.rolls || 0;
|
||||
factoryInventories[key].meters += r.meters || 0;
|
||||
factoryInventories[key].records.push(r);
|
||||
});
|
||||
|
||||
// 获取入库记录
|
||||
let textileInventoryRecords: InventoryRecord[] = [];
|
||||
if (planIds.length > 0) {
|
||||
const { data: records } = await supabase
|
||||
.from('inventory_records')
|
||||
.select('plan_id, quantity, rolls, warehouse_location')
|
||||
.in('plan_id', planIds);
|
||||
textileInventoryRecords = (records || []) as InventoryRecord[];
|
||||
}
|
||||
|
||||
// 处理数据
|
||||
const updatedProducts = processInventoryData({
|
||||
products: productsData,
|
||||
plansData: typedPlansData,
|
||||
textileInventoryRecords,
|
||||
planToFactoryName,
|
||||
batchesData,
|
||||
outboundData
|
||||
inRecords.forEach(r => {
|
||||
allBatches.push({
|
||||
id: r.id,
|
||||
product_id: r.product_id,
|
||||
company_id: r.company_id || '',
|
||||
batch_no: r.batch_no,
|
||||
rolls: r.rolls,
|
||||
meters: r.meters,
|
||||
notes: r.notes,
|
||||
created_at: r.created_at,
|
||||
product: p,
|
||||
source: r.source,
|
||||
factory_name: r.factory_name,
|
||||
factory_id: r.factory_id,
|
||||
} as FabricBatch);
|
||||
});
|
||||
|
||||
// 为 batches 数据注入产品信息
|
||||
const batchesWithProduct: FabricBatch[] = batchesData.map((batch: ProductInventoryRecord) => {
|
||||
const product = productsData.find((p: Product) => p.id === batch.product_id);
|
||||
return {
|
||||
id: batch.id,
|
||||
product_id: batch.product_id,
|
||||
company_id: batch.company_id || '',
|
||||
batch_no: batch.batch_no,
|
||||
rolls: batch.rolls,
|
||||
meters: batch.meters,
|
||||
notes: batch.notes,
|
||||
created_at: batch.created_at,
|
||||
product: product || undefined
|
||||
} as FabricBatch;
|
||||
...p,
|
||||
production_price: p.production_price || 0,
|
||||
raw_fabric_rolls: Math.max(0, totalInRolls - totalOutRolls),
|
||||
raw_fabric_meters: Math.max(0, totalInMeters - totalOutMeters),
|
||||
total_stock: Math.max(0, totalInMeters - totalOutMeters),
|
||||
factory_inventories: factoryInventories,
|
||||
in_records: inRecords,
|
||||
out_records: outRecords,
|
||||
total_in_rolls: totalInRolls,
|
||||
total_in_meters: totalInMeters,
|
||||
total_out_rolls: totalOutRolls,
|
||||
total_out_meters: totalOutMeters,
|
||||
} as ProductWithInventory;
|
||||
});
|
||||
|
||||
const newState: InventoryDataState = {
|
||||
products: updatedProducts,
|
||||
productRatios: ratiosMap,
|
||||
batches: batchesWithProduct,
|
||||
productRatios: {},
|
||||
batches: allBatches,
|
||||
loading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
// 更新缓存
|
||||
cacheRef.current = {
|
||||
data: newState,
|
||||
timestamp: now,
|
||||
@ -238,9 +162,7 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 使用统一的实时订阅管理器
|
||||
useInventoryRealtime(companyId, () => {
|
||||
// 清除缓存并刷新
|
||||
cacheRef.current.timestamp = 0;
|
||||
fetchData();
|
||||
});
|
||||
@ -250,189 +172,3 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
|
||||
refetch: fetchData
|
||||
};
|
||||
}
|
||||
|
||||
// 处理库存数据的辅助函数
|
||||
interface ProcessInventoryDataParams {
|
||||
products: Product[];
|
||||
plansData: PlanSummary[];
|
||||
textileInventoryRecords: InventoryRecord[];
|
||||
planToFactoryName: Record<string, string>;
|
||||
batchesData: ProductInventoryRecord[];
|
||||
outboundData: ProductOutboundRecord[];
|
||||
}
|
||||
|
||||
function processInventoryData({
|
||||
products,
|
||||
plansData,
|
||||
textileInventoryRecords,
|
||||
planToFactoryName,
|
||||
batchesData,
|
||||
outboundData
|
||||
}: ProcessInventoryDataParams): ProductWithInventory[] {
|
||||
// 建立 plan_id 到 fabric_code 的映射
|
||||
const planToFabricCode: Record<string, string> = {};
|
||||
const planCompletedMap: Record<string, number> = {};
|
||||
plansData.forEach((plan: PlanSummary) => {
|
||||
planToFabricCode[plan.id] = plan.fabric_code;
|
||||
planCompletedMap[plan.id] = plan.completed_quantity || 0;
|
||||
});
|
||||
|
||||
// 按 plan_id 汇总库存
|
||||
const planInventoryMap: Record<string, { meters: number; rolls: number; factory_id: string; factory_name: string }> = {};
|
||||
|
||||
textileInventoryRecords.forEach((r: InventoryRecord) => {
|
||||
const factoryName = planToFactoryName[r.plan_id] || r.warehouse_location || '纺织厂';
|
||||
const factoryId = planToFactoryName[r.plan_id] || r.warehouse_location || `factory_${r.plan_id}`;
|
||||
if (!planInventoryMap[r.plan_id]) {
|
||||
planInventoryMap[r.plan_id] = { meters: 0, rolls: 0, factory_id: factoryId, factory_name: factoryName };
|
||||
}
|
||||
planInventoryMap[r.plan_id].meters += (Number(r.quantity) || 0);
|
||||
planInventoryMap[r.plan_id].rolls += (Number(r.rolls) || 0);
|
||||
});
|
||||
|
||||
// 汇总每个坯布码的库存
|
||||
const fabricCodeInventory: Record<string, { meters: number; rolls: number }> = {};
|
||||
const fabricCodeFactoryInventory: Record<string, Record<string, { factory_id: string; factory_name: string; rolls: number; meters: number; records: ProductInRecord[] }>> = {};
|
||||
|
||||
Object.entries(planInventoryMap).forEach(([planId, inventory]) => {
|
||||
const fabricCode = planToFabricCode[planId];
|
||||
if (fabricCode) {
|
||||
if (!fabricCodeInventory[fabricCode]) {
|
||||
fabricCodeInventory[fabricCode] = { meters: 0, rolls: 0 };
|
||||
}
|
||||
fabricCodeInventory[fabricCode].meters += inventory.meters || 0;
|
||||
fabricCodeInventory[fabricCode].rolls += inventory.rolls || 0;
|
||||
|
||||
if (!fabricCodeFactoryInventory[fabricCode]) {
|
||||
fabricCodeFactoryInventory[fabricCode] = {};
|
||||
}
|
||||
const factoryKey = inventory.factory_name || 'unknown';
|
||||
if (!fabricCodeFactoryInventory[fabricCode][factoryKey]) {
|
||||
fabricCodeFactoryInventory[fabricCode][factoryKey] = {
|
||||
factory_id: inventory.factory_id || 'unknown',
|
||||
factory_name: inventory.factory_name || '纺织厂',
|
||||
rolls: 0,
|
||||
meters: 0,
|
||||
records: []
|
||||
};
|
||||
}
|
||||
fabricCodeFactoryInventory[fabricCode][factoryKey].rolls += inventory.rolls || 0;
|
||||
fabricCodeFactoryInventory[fabricCode][factoryKey].meters += inventory.meters || 0;
|
||||
}
|
||||
});
|
||||
|
||||
// 处理入库记录 - 合并自录入和纺织厂入库
|
||||
const inRecordsMap: Record<string, ProductInRecord[]> = {};
|
||||
|
||||
// 1. 先添加自录入的入库记录
|
||||
batchesData.forEach((b: ProductInventoryRecord) => {
|
||||
if (!inRecordsMap[b.product_id]) {
|
||||
inRecordsMap[b.product_id] = [];
|
||||
}
|
||||
inRecordsMap[b.product_id].push({
|
||||
id: b.id,
|
||||
product_id: b.product_id,
|
||||
company_id: b.company_id,
|
||||
batch_no: b.batch_no,
|
||||
rolls: b.rolls || 0,
|
||||
meters: b.meters || 0,
|
||||
notes: b.notes,
|
||||
operator_id: b.operator_id,
|
||||
warehouse_id: b.warehouse_id,
|
||||
created_at: b.created_at,
|
||||
time: b.created_at,
|
||||
source: 'self'
|
||||
});
|
||||
});
|
||||
|
||||
// 2. 添加纺织厂的入库记录
|
||||
// 建立 fabric_code 到 product_id 的映射
|
||||
const fabricCodeToProductId: Record<string, string> = {};
|
||||
products.forEach((p: Product) => {
|
||||
fabricCodeToProductId[p.fabric_code] = p.id;
|
||||
});
|
||||
|
||||
// 将纺织厂入库记录按 fabric_code 关联到对应产品
|
||||
textileInventoryRecords.forEach((r: InventoryRecord) => {
|
||||
const fabricCode = planToFabricCode[r.plan_id];
|
||||
const productId = fabricCodeToProductId[fabricCode];
|
||||
if (productId) {
|
||||
if (!inRecordsMap[productId]) {
|
||||
inRecordsMap[productId] = [];
|
||||
}
|
||||
const factoryName = planToFactoryName[r.plan_id] || r.warehouse_location || '纺织厂';
|
||||
inRecordsMap[productId].push({
|
||||
id: r.id || `${r.plan_id}-${r.created_at}`,
|
||||
product_id: productId,
|
||||
company_id: r.company_id,
|
||||
batch_no: '-',
|
||||
rolls: Number(r.rolls) || 0,
|
||||
meters: Number(r.quantity) || 0,
|
||||
notes: `来自${factoryName}`,
|
||||
operator_id: r.operator_id,
|
||||
warehouse_id: r.warehouse_id,
|
||||
created_at: r.created_at,
|
||||
time: r.created_at,
|
||||
factory_name: factoryName,
|
||||
factory_id: r.plan_id,
|
||||
source: 'textile'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 按时间排序
|
||||
Object.keys(inRecordsMap).forEach(productId => {
|
||||
inRecordsMap[productId].sort((a, b) => new Date(b.time || b.created_at).getTime() - new Date(a.time || a.created_at).getTime());
|
||||
});
|
||||
|
||||
// 处理出库记录 - 按 product_id 汇总
|
||||
const outboundMap: Record<string, { totalRolls: number; totalMeters: number; records: ProductOutboundRecord[] }> = {};
|
||||
outboundData.forEach((r: ProductOutboundRecord) => {
|
||||
if (!outboundMap[r.product_id]) {
|
||||
outboundMap[r.product_id] = { totalRolls: 0, totalMeters: 0, records: [] };
|
||||
}
|
||||
outboundMap[r.product_id].totalRolls += Number(r.rolls) || 0;
|
||||
outboundMap[r.product_id].totalMeters += Number(r.meters) || 0;
|
||||
outboundMap[r.product_id].records.push(r);
|
||||
});
|
||||
|
||||
// 计算每个产品的自录入库总量(从 batchesData)
|
||||
const selfInventoryMap: Record<string, { rolls: number; meters: number }> = {};
|
||||
batchesData.forEach((b: ProductInventoryRecord) => {
|
||||
if (!selfInventoryMap[b.product_id]) {
|
||||
selfInventoryMap[b.product_id] = { rolls: 0, meters: 0 };
|
||||
}
|
||||
selfInventoryMap[b.product_id].rolls += Number(b.rolls) || 0;
|
||||
selfInventoryMap[b.product_id].meters += Number(b.meters) || 0;
|
||||
});
|
||||
|
||||
// 更新产品数据 - 扣除出库数量
|
||||
return products.map((p: Product) => {
|
||||
const textileInventory = fabricCodeInventory[p.fabric_code] || { meters: 0, rolls: 0 };
|
||||
const factoryInventories = fabricCodeFactoryInventory[p.fabric_code] || {};
|
||||
// 使用实时计算的入库记录,而不是 products 表中的字段
|
||||
const selfInventory = selfInventoryMap[p.id] || { rolls: 0, meters: 0 };
|
||||
const outboundInfo = outboundMap[p.id] || { totalRolls: 0, totalMeters: 0, records: [] };
|
||||
|
||||
// 计算实际可用库存(入库 - 出库)
|
||||
const totalInRolls = selfInventory.rolls + textileInventory.rolls;
|
||||
const totalInMeters = selfInventory.meters + textileInventory.meters;
|
||||
const availableRolls = Math.max(0, totalInRolls - outboundInfo.totalRolls);
|
||||
const availableMeters = Math.max(0, totalInMeters - outboundInfo.totalMeters);
|
||||
|
||||
return {
|
||||
...p,
|
||||
production_price: p.production_price || 0,
|
||||
raw_fabric_rolls: availableRolls,
|
||||
raw_fabric_meters: availableMeters,
|
||||
total_stock: availableMeters,
|
||||
factory_inventories: factoryInventories,
|
||||
in_records: inRecordsMap[p.id] || [],
|
||||
out_records: outboundInfo.records,
|
||||
total_in_rolls: totalInRolls,
|
||||
total_in_meters: totalInMeters,
|
||||
total_out_rolls: outboundInfo.totalRolls,
|
||||
total_out_meters: outboundInfo.totalMeters
|
||||
};
|
||||
}) as ProductWithInventory[];
|
||||
}
|
||||
|
||||
@ -1,16 +1,13 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { statusMap } from '../utils/constants';
|
||||
import { purchaserApi } from '../api';
|
||||
import type { PlanDetail } from '../api/purchaser';
|
||||
import { CACHE_CONFIG, PAGINATION_CONFIG } from '../config/app';
|
||||
import { usePlanRealtime } from './useRealtime';
|
||||
import type {
|
||||
ProductionPlan,
|
||||
PlanWithSteps,
|
||||
PlanProcessStep,
|
||||
Company,
|
||||
PlanFactory,
|
||||
YarnRatio,
|
||||
Product
|
||||
} from '../types';
|
||||
|
||||
interface UsePlanDataOptions {
|
||||
@ -31,10 +28,15 @@ interface PlanDataState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化的计划数据获取 Hook
|
||||
* 支持分页加载和缓存
|
||||
*/
|
||||
function mapPlanDetail(plan: PlanDetail): PlanWithSteps {
|
||||
return {
|
||||
...plan,
|
||||
processSteps: plan.process_steps,
|
||||
inventoryRecords: plan.inventory_records,
|
||||
priceHistory: plan.price_history,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPageSize }: UsePlanDataOptions) {
|
||||
const [state, setState] = useState<PlanDataState>({
|
||||
plans: [],
|
||||
@ -49,7 +51,6 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
|
||||
error: null
|
||||
});
|
||||
|
||||
// 使用 ref 存储缓存,避免不必要的重渲染
|
||||
const cacheRef = useRef<{
|
||||
data: PlanDataState | null;
|
||||
timestamp: number;
|
||||
@ -57,83 +58,6 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
|
||||
|
||||
const CACHE_DURATION = CACHE_CONFIG.planDataDuration;
|
||||
|
||||
// 获取操作人名称
|
||||
const fetchOperatorNames = useCallback(async (operatorIds: string[]) => {
|
||||
if (operatorIds.length === 0) return {};
|
||||
|
||||
const { data: profilesData } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, username')
|
||||
.in('id', operatorIds);
|
||||
|
||||
const names: Record<string, string> = {};
|
||||
profilesData?.forEach(p => {
|
||||
names[p.id] = p.username;
|
||||
});
|
||||
return names;
|
||||
}, []);
|
||||
|
||||
// 处理计划数据
|
||||
const processPlansData = useCallback((
|
||||
planData: ProductionPlan[],
|
||||
stepsData: PlanProcessStep[] | null,
|
||||
inventoryData: any[] | null,
|
||||
priceHistoryData: any[] | null,
|
||||
yarnRatiosData: any[] | null,
|
||||
productsData: Product[] | null
|
||||
): PlanWithSteps[] => {
|
||||
return planData.map(plan => {
|
||||
const inventoryRecords = (inventoryData || [])
|
||||
.filter(r => r.plan_id === plan.id)
|
||||
.map(r => ({ time: r.created_at, quantity: r.quantity, rolls: r.rolls }));
|
||||
|
||||
const priceHistory = (priceHistoryData || [])
|
||||
.filter(h => h.plan_id === plan.id)
|
||||
.map(h => ({
|
||||
id: h.id,
|
||||
old_price: h.old_price,
|
||||
new_price: h.new_price,
|
||||
change_amount: h.change_amount,
|
||||
operator_name: h.operator_name,
|
||||
change_reason: h.change_reason,
|
||||
created_at: h.created_at
|
||||
}));
|
||||
|
||||
const yarnRatios = (yarnRatiosData || [])
|
||||
.filter(y => y.plan_id === plan.id)
|
||||
.map(y => ({
|
||||
id: y.id,
|
||||
plan_id: y.plan_id,
|
||||
yarn_name: y.yarn_name,
|
||||
ratio: y.ratio,
|
||||
amount_per_meter: y.amount_per_meter,
|
||||
total_amount: y.total_amount,
|
||||
yarn_type: y.yarn_type,
|
||||
created_at: y.created_at
|
||||
}));
|
||||
|
||||
// 查找关联的产品信息(通过fabric_code匹配)
|
||||
const product = (productsData || []).find(p =>
|
||||
p.fabric_code === plan.fabric_code && p.company_id === plan.purchaser_id
|
||||
);
|
||||
|
||||
return {
|
||||
...plan,
|
||||
processSteps: (stepsData || []).filter(s => s.plan_id === plan.id),
|
||||
inventoryRecords,
|
||||
priceHistory,
|
||||
yarnRatios,
|
||||
// 从产品表获取的字段
|
||||
image_url: product?.image_url || null,
|
||||
warp_weight: product?.warp_weight || null,
|
||||
weft_weight: product?.weft_weight || null,
|
||||
total_weight: product?.total_weight || null,
|
||||
weight: product?.weight || null
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 获取数据
|
||||
const fetchData = useCallback(async (page: number = 1, append: boolean = false) => {
|
||||
if (!companyId) {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
@ -143,7 +67,6 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
|
||||
const isFirstPage = page === 1;
|
||||
|
||||
if (isFirstPage) {
|
||||
// 检查缓存
|
||||
const now = Date.now();
|
||||
if (cacheRef.current.data && now - cacheRef.current.timestamp < CACHE_DURATION) {
|
||||
setState(cacheRef.current.data);
|
||||
@ -155,114 +78,32 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
|
||||
}
|
||||
|
||||
try {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const res = await purchaserApi.listPlans({ page, page_size: pageSize });
|
||||
const { plans: planDetails, factories, plan_factories, operator_names } = res.data;
|
||||
|
||||
// 获取计划
|
||||
const { data: planData, error: planError } = await supabase
|
||||
.from('production_plans')
|
||||
.select('*')
|
||||
.eq('purchaser_id', companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + pageSize - 1);
|
||||
const plansWithSteps = planDetails.map(mapPlanDetail);
|
||||
|
||||
if (planError) throw planError;
|
||||
|
||||
if (!planData || planData.length === 0) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
hasMore: false,
|
||||
plans: isFirstPage ? [] : prev.plans
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const planIds = planData.map(p => p.id);
|
||||
|
||||
// 获取计划对应的采购商ID列表
|
||||
const purchaserIds = [...new Set(planData.map(p => p.purchaser_id))];
|
||||
|
||||
// 并行获取关联数据
|
||||
const [
|
||||
stepsResult,
|
||||
inventoryResult,
|
||||
priceHistoryResult,
|
||||
pfResult,
|
||||
yarnRatiosResult,
|
||||
productsResult
|
||||
] = await Promise.all([
|
||||
supabase.from('plan_process_steps').select('*').in('plan_id', planIds),
|
||||
supabase
|
||||
.from('inventory_records')
|
||||
.select('plan_id, quantity, rolls, created_at')
|
||||
.in('plan_id', planIds)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100),
|
||||
supabase
|
||||
.from('production_price_history')
|
||||
.select('*')
|
||||
.in('plan_id', planIds)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50),
|
||||
supabase.from('plan_factories').select('*').in('plan_id', planIds),
|
||||
supabase.from('yarn_ratios').select('*').in('plan_id', planIds),
|
||||
// 获取采购商的产品信息(用于获取克重、图片等)
|
||||
supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.in('company_id', purchaserIds)
|
||||
]);
|
||||
|
||||
// 获取操作人信息
|
||||
const operatorIds = [...new Set(
|
||||
(stepsResult.data || [])
|
||||
.filter(s => s.operator_id)
|
||||
.map(s => s.operator_id)
|
||||
.filter(Boolean)
|
||||
)] as string[];
|
||||
|
||||
const operatorNames = await fetchOperatorNames(operatorIds);
|
||||
|
||||
// 获取工厂信息(包括所有纺织厂和水洗厂,不仅限于有关联的)
|
||||
const { data: allFactories } = await supabase
|
||||
.from('companies')
|
||||
.select('*')
|
||||
.in('role', ['textile', 'washing']);
|
||||
const factories = allFactories || [];
|
||||
|
||||
// 处理计划数据
|
||||
const plansWithSteps = processPlansData(
|
||||
planData,
|
||||
stepsResult.data,
|
||||
inventoryResult.data,
|
||||
priceHistoryResult.data,
|
||||
yarnRatiosResult.data,
|
||||
productsResult.data
|
||||
);
|
||||
|
||||
// 构建纱线配比映射
|
||||
const yarnRatiosMap: Record<string, YarnRatio[]> = {};
|
||||
(yarnRatiosResult.data || []).forEach((y: any) => {
|
||||
if (!yarnRatiosMap[y.plan_id]) yarnRatiosMap[y.plan_id] = [];
|
||||
yarnRatiosMap[y.plan_id].push(y as YarnRatio);
|
||||
planDetails.forEach(p => {
|
||||
if (p.yarn_ratios && p.yarn_ratios.length > 0) {
|
||||
yarnRatiosMap[p.id] = p.yarn_ratios;
|
||||
}
|
||||
});
|
||||
|
||||
setState(prev => {
|
||||
const newState: PlanDataState = {
|
||||
plans: append ? [...prev.plans, ...plansWithSteps] : plansWithSteps,
|
||||
factories: isFirstPage ? factories : prev.factories,
|
||||
planFactories: isFirstPage ? (pfResult.data || []) : prev.planFactories,
|
||||
operatorNames: isFirstPage ? operatorNames : { ...prev.operatorNames, ...operatorNames },
|
||||
planFactories: isFirstPage ? plan_factories : prev.planFactories,
|
||||
operatorNames: isFirstPage ? operator_names : { ...prev.operatorNames, ...operator_names },
|
||||
yarnRatios: isFirstPage ? yarnRatiosMap : { ...prev.yarnRatios, ...yarnRatiosMap },
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
hasMore: planData.length === pageSize,
|
||||
hasMore: res.meta?.has_more ?? planDetails.length === pageSize,
|
||||
currentPage: page,
|
||||
error: null
|
||||
};
|
||||
|
||||
// 更新缓存
|
||||
if (isFirstPage) {
|
||||
cacheRef.current = { data: newState, timestamp: Date.now() };
|
||||
}
|
||||
@ -277,26 +118,22 @@ export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPag
|
||||
error: err instanceof Error ? err : new Error('Unknown error')
|
||||
}));
|
||||
}
|
||||
}, [companyId, pageSize, fetchOperatorNames, processPlansData]);
|
||||
}, [companyId, pageSize]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
if (state.loadingMore || !state.hasMore) return;
|
||||
fetchData(state.currentPage + 1, true);
|
||||
}, [fetchData, state.currentPage, state.hasMore, state.loadingMore]);
|
||||
|
||||
// 刷新数据
|
||||
const refetch = useCallback(() => {
|
||||
cacheRef.current.timestamp = 0;
|
||||
fetchData(1, false);
|
||||
}, [fetchData]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
fetchData(1, false);
|
||||
}, [fetchData]);
|
||||
|
||||
// 使用统一的实时订阅管理器
|
||||
usePlanRealtime(companyId, () => {
|
||||
cacheRef.current.timestamp = 0;
|
||||
fetchData(1, false);
|
||||
|
||||
@ -1,120 +1,20 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { SYNC_CONFIG } from '../config/app';
|
||||
import { PLAN_STATUS, STEP_STATUS } from '../utils/constants';
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { wsClient } from '../api/websocket';
|
||||
|
||||
interface PlanStatusCheckResult {
|
||||
planId: string;
|
||||
planCode: string;
|
||||
stepStatus: string;
|
||||
planStatus: string;
|
||||
needsFix: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查计划状态是否一致
|
||||
* 如果 confirm 步骤已完成但计划状态仍为 pending,则需要修复
|
||||
*/
|
||||
export function usePlanStatusSync(companyId: string | undefined) {
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const isCheckingRef = useRef(false);
|
||||
|
||||
const checkAndFixPlanStatus = useCallback(async () => {
|
||||
if (!companyId || isCheckingRef.current) return;
|
||||
|
||||
isCheckingRef.current = true;
|
||||
|
||||
try {
|
||||
// 获取当前纺织厂关联的所有计划
|
||||
const { data: planFactories } = await supabase
|
||||
.from('plan_factories')
|
||||
.select('plan_id')
|
||||
.eq('factory_id', companyId)
|
||||
.eq('factory_type', 'textile');
|
||||
|
||||
if (!planFactories || planFactories.length === 0) {
|
||||
isCheckingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const planIds = planFactories.map(pf => pf.plan_id);
|
||||
|
||||
// 获取这些计划的流程步骤状态
|
||||
const { data: stepsData } = await supabase
|
||||
.from('plan_process_steps')
|
||||
.select('plan_id, step_type, status')
|
||||
.in('plan_id', planIds)
|
||||
.eq('step_type', 'confirm');
|
||||
|
||||
// 获取计划当前状态
|
||||
const { data: plansData } = await supabase
|
||||
.from('production_plans')
|
||||
.select('id, plan_code, status')
|
||||
.in('id', planIds);
|
||||
|
||||
if (!stepsData || !plansData) {
|
||||
isCheckingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查状态不一致的计划
|
||||
const inconsistentPlans: PlanStatusCheckResult[] = [];
|
||||
|
||||
for (const plan of plansData) {
|
||||
const confirmStep = stepsData.find(s => s.plan_id === plan.id);
|
||||
|
||||
// 如果 confirm 步骤已完成但计划状态仍为 pending,则需要修复
|
||||
if (confirmStep?.status === STEP_STATUS.COMPLETED && plan.status === PLAN_STATUS.PENDING) {
|
||||
inconsistentPlans.push({
|
||||
planId: plan.id,
|
||||
planCode: plan.plan_code,
|
||||
stepStatus: confirmStep.status,
|
||||
planStatus: plan.status,
|
||||
needsFix: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 自动修复状态不一致的计划
|
||||
if (inconsistentPlans.length > 0) {
|
||||
console.log(`[PlanStatusSync] 发现 ${inconsistentPlans.length} 个状态不一致的计划:`, inconsistentPlans);
|
||||
|
||||
for (const item of inconsistentPlans) {
|
||||
const { error } = await supabase
|
||||
.from('production_plans')
|
||||
.update({ status: PLAN_STATUS.PRODUCING })
|
||||
.eq('id', item.planId);
|
||||
|
||||
if (error) {
|
||||
console.error(`[PlanStatusSync] 修复计划 ${item.planCode} 失败:`, error);
|
||||
} else {
|
||||
console.log(`[PlanStatusSync] 已自动修复计划 ${item.planCode}: pending → producing`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PlanStatusSync] 检查失败:', err);
|
||||
} finally {
|
||||
isCheckingRef.current = false;
|
||||
}
|
||||
}, [companyId]);
|
||||
// Server handles plan status sync and pushes updates via WebSocket
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyId) return;
|
||||
|
||||
// 立即执行一次检查
|
||||
checkAndFixPlanStatus();
|
||||
const unsub = wsClient.subscribe('production_plans', () => {
|
||||
// Server pushes status corrections; UI will refresh via other hooks
|
||||
});
|
||||
|
||||
// 同步间隔由配置中心管理(开发环境10秒,生产环境5分钟)
|
||||
intervalRef.current = setInterval(checkAndFixPlanStatus, SYNC_CONFIG.planStatusSyncInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [companyId, checkAndFixPlanStatus]);
|
||||
return unsub;
|
||||
}, [companyId]);
|
||||
|
||||
return { checkAndFixPlanStatus };
|
||||
}
|
||||
|
||||
@ -1,105 +1,14 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { wsClient } from '../api/websocket';
|
||||
|
||||
// 订阅配置类型
|
||||
interface SubscriptionConfig {
|
||||
table: string;
|
||||
event?: '*' | 'INSERT' | 'UPDATE' | 'DELETE';
|
||||
filter?: string;
|
||||
}
|
||||
|
||||
// 回调函数类型
|
||||
type ChangeCallback = () => void;
|
||||
|
||||
// 全局订阅管理器(单例模式)
|
||||
class RealtimeSubscriptionManager {
|
||||
private static instance: RealtimeSubscriptionManager;
|
||||
private subscriptions: Map<string, { count: number; channel: any }> = new Map();
|
||||
private callbacks: Map<string, Set<ChangeCallback>> = new Map();
|
||||
|
||||
static getInstance(): RealtimeSubscriptionManager {
|
||||
if (!RealtimeSubscriptionManager.instance) {
|
||||
RealtimeSubscriptionManager.instance = new RealtimeSubscriptionManager();
|
||||
}
|
||||
return RealtimeSubscriptionManager.instance;
|
||||
}
|
||||
|
||||
// 生成订阅唯一键
|
||||
private getKey(config: SubscriptionConfig): string {
|
||||
return `${config.table}:${config.event || '*'}:${config.filter || 'all'}`;
|
||||
}
|
||||
|
||||
// 订阅
|
||||
subscribe(config: SubscriptionConfig, callback: ChangeCallback): () => void {
|
||||
const key = this.getKey(config);
|
||||
|
||||
// 添加回调
|
||||
if (!this.callbacks.has(key)) {
|
||||
this.callbacks.set(key, new Set());
|
||||
}
|
||||
this.callbacks.get(key)!.add(callback);
|
||||
|
||||
// 如果该订阅已存在,增加引用计数
|
||||
if (this.subscriptions.has(key)) {
|
||||
const sub = this.subscriptions.get(key)!;
|
||||
sub.count++;
|
||||
return () => this.unsubscribe(key, callback);
|
||||
}
|
||||
|
||||
// 创建新订阅
|
||||
const channel = supabase
|
||||
.channel(`realtime_${key}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: config.event || '*',
|
||||
schema: 'public',
|
||||
table: config.table,
|
||||
filter: config.filter
|
||||
},
|
||||
() => {
|
||||
// 触发所有回调
|
||||
this.callbacks.get(key)?.forEach(cb => cb());
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.subscriptions.set(key, { count: 1, channel });
|
||||
|
||||
// 返回取消订阅函数
|
||||
return () => this.unsubscribe(key, callback);
|
||||
}
|
||||
|
||||
// 取消订阅
|
||||
private unsubscribe(key: string, callback: ChangeCallback): void {
|
||||
// 移除回调
|
||||
this.callbacks.get(key)?.delete(callback);
|
||||
|
||||
// 减少引用计数
|
||||
const sub = this.subscriptions.get(key);
|
||||
if (sub) {
|
||||
sub.count--;
|
||||
|
||||
// 如果没有引用,取消订阅
|
||||
if (sub.count <= 0) {
|
||||
sub.channel.unsubscribe();
|
||||
this.subscriptions.delete(key);
|
||||
this.callbacks.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局实例
|
||||
const subscriptionManager = RealtimeSubscriptionManager.getInstance();
|
||||
|
||||
/**
|
||||
* 统一的实时订阅 Hook
|
||||
* 自动管理订阅生命周期,避免重复订阅
|
||||
* @param config 订阅配置
|
||||
* @param callback 数据变化回调
|
||||
* @param enabled 是否启用订阅(默认 true)
|
||||
*/
|
||||
export function useRealtime(
|
||||
config: SubscriptionConfig,
|
||||
callback: ChangeCallback,
|
||||
@ -107,7 +16,6 @@ export function useRealtime(
|
||||
): void {
|
||||
const callbackRef = useRef(callback);
|
||||
|
||||
// 保持回调引用最新
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
@ -115,30 +23,22 @@ export function useRealtime(
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
// 包装回调,确保使用最新引用
|
||||
const wrappedCallback = () => {
|
||||
callbackRef.current();
|
||||
};
|
||||
|
||||
// 订阅
|
||||
const unsubscribe = subscriptionManager.subscribe(config, wrappedCallback);
|
||||
const unsubscribe = wsClient.subscribe(config.table, wrappedCallback);
|
||||
|
||||
// 清理
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [config.table, config.event, config.filter, enabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 库存数据实时订阅 Hook
|
||||
* 合并 inventory_records 和 product_inventory_records 订阅
|
||||
*/
|
||||
export function useInventoryRealtime(
|
||||
companyId: string | null,
|
||||
onChange: ChangeCallback
|
||||
): void {
|
||||
// 使用 ref 保持回调稳定
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
@ -149,17 +49,8 @@ export function useInventoryRealtime(
|
||||
|
||||
const wrappedCallback = () => onChangeRef.current();
|
||||
|
||||
// 订阅 inventory_records 变化
|
||||
const unsub1 = subscriptionManager.subscribe(
|
||||
{ table: 'inventory_records', event: '*' },
|
||||
wrappedCallback
|
||||
);
|
||||
|
||||
// 订阅 product_inventory_records 变化
|
||||
const unsub2 = subscriptionManager.subscribe(
|
||||
{ table: 'product_inventory_records', event: '*' },
|
||||
wrappedCallback
|
||||
);
|
||||
const unsub1 = wsClient.subscribe('inventory_records', wrappedCallback);
|
||||
const unsub2 = wsClient.subscribe('product_inventory_records', wrappedCallback);
|
||||
|
||||
return () => {
|
||||
unsub1();
|
||||
@ -168,9 +59,6 @@ export function useInventoryRealtime(
|
||||
}, [companyId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计划数据实时订阅 Hook
|
||||
*/
|
||||
export function usePlanRealtime(
|
||||
companyId: string | null,
|
||||
onChange: ChangeCallback
|
||||
@ -185,11 +73,7 @@ export function usePlanRealtime(
|
||||
|
||||
const wrappedCallback = () => onChangeRef.current();
|
||||
|
||||
// 订阅 inventory_records 变化(影响计划完成进度)
|
||||
const unsub = subscriptionManager.subscribe(
|
||||
{ table: 'inventory_records', event: '*' },
|
||||
wrappedCallback
|
||||
);
|
||||
const unsub = wsClient.subscribe('inventory_records', wrappedCallback);
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
@ -197,16 +81,6 @@ export function usePlanRealtime(
|
||||
}, [companyId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发刷新(用于需要主动刷新的场景)
|
||||
*/
|
||||
export function triggerRealtimeRefresh(table: string): void {
|
||||
// 触发该表的所有订阅回调
|
||||
const manager = RealtimeSubscriptionManager.getInstance();
|
||||
// 遍历所有订阅,找到匹配的表
|
||||
manager['subscriptions'].forEach((_, key) => {
|
||||
if (key.startsWith(`${table}:`)) {
|
||||
manager['callbacks'].get(key)?.forEach(cb => cb());
|
||||
}
|
||||
});
|
||||
export function triggerRealtimeRefresh(_table: string): void {
|
||||
// no-op: server pushes changes via WebSocket
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { http } from '../api';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, CheckCircle, AlertTriangle, Package, Droplet, Link2, Clock, XCircle, Building2 } from 'lucide-react';
|
||||
import { decryptShareParams } from '../utils/shareCrypto';
|
||||
import { useResponsive } from '../hooks/useResponsive';
|
||||
import { useErrorHandler } from '../hooks/useErrorHandler';
|
||||
import { PLAN_STATUS, SHARE_LINK_STATUS } from '../utils/constants';
|
||||
import { SHARE_LINK_STATUS } from '../utils/constants';
|
||||
import type { ProductionPlan, WashingPlan, YarnRatio, FactoryType } from '../types';
|
||||
|
||||
const containerVariants = {
|
||||
@ -50,30 +50,20 @@ export function ImportPlanPage() {
|
||||
useEffect(() => {
|
||||
const resolveLink = async () => {
|
||||
if (shortCode) {
|
||||
// 短链解析
|
||||
const { data, error } = await supabase
|
||||
.from('share_links')
|
||||
.select('*')
|
||||
.eq('short_code', shortCode)
|
||||
.maybeSingle();
|
||||
try {
|
||||
const res = await http.get<any>(`/common/share-links/${shortCode}`);
|
||||
const data = res.data;
|
||||
|
||||
if (data) {
|
||||
// 检查链接状态
|
||||
setShareLinkStatus(data.status as ShareLinkStatus);
|
||||
|
||||
// 检查是否过期
|
||||
const expiresAt = data.expires_at ? new Date(data.expires_at) : null;
|
||||
const now = new Date();
|
||||
if (expiresAt && expiresAt < now) {
|
||||
setLinkExpired(true);
|
||||
// 更新状态为过期
|
||||
await supabase
|
||||
.from('share_links')
|
||||
.update({ status: SHARE_LINK_STATUS.EXPIRED })
|
||||
.eq('short_code', shortCode);
|
||||
await http.put(`/common/share-links/${shortCode}`, { status: SHARE_LINK_STATUS.EXPIRED });
|
||||
}
|
||||
|
||||
// 检查是否已被使用(confirmed/rejected/expired)
|
||||
const usedStatuses: ShareLinkStatus[] = [SHARE_LINK_STATUS.CONFIRMED as ShareLinkStatus, SHARE_LINK_STATUS.REJECTED as ShareLinkStatus, SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus, SHARE_LINK_STATUS.CANCELLED as ShareLinkStatus];
|
||||
if (usedStatuses.includes(data.status as ShareLinkStatus)) {
|
||||
setDecrypting(false);
|
||||
@ -84,34 +74,28 @@ export function ImportPlanPage() {
|
||||
setFactoryType(data.factory_type as FactoryType);
|
||||
setHideSensitive(data.hide_sensitive || false);
|
||||
|
||||
// 如果状态是 pending,更新为 clicked(首次点击)
|
||||
if (data.status === SHARE_LINK_STATUS.PENDING) {
|
||||
const { error: updateError } = await supabase
|
||||
.from('share_links')
|
||||
.update({
|
||||
try {
|
||||
await http.put(`/common/share-links/${shortCode}`, {
|
||||
status: SHARE_LINK_STATUS.CLICKED,
|
||||
clicked_at: new Date().toISOString()
|
||||
})
|
||||
.eq('short_code', shortCode);
|
||||
|
||||
if (updateError) {
|
||||
handleError(updateError, { userMessage: '更新链接点击状态失败', context: { shortCode } });
|
||||
} else {
|
||||
});
|
||||
setShareLinkStatus(SHARE_LINK_STATUS.CLICKED as ShareLinkStatus);
|
||||
} catch (err) {
|
||||
handleError(err, { userMessage: '更新链接点击状态失败', context: { shortCode } });
|
||||
}
|
||||
setLinkClicked(true);
|
||||
} else if (data.status === SHARE_LINK_STATUS.CLICKED) {
|
||||
setLinkClicked(true);
|
||||
}
|
||||
|
||||
// 增加点击计数
|
||||
await supabase.rpc('increment_share_link_clicks', { code: shortCode });
|
||||
} else {
|
||||
handleError(error, { userMessage: '短链查询失败', context: { shortCode } });
|
||||
await http.post(`/common/share-links/${shortCode}/click`).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, { userMessage: '短链查询失败', context: { shortCode } });
|
||||
}
|
||||
setDecrypting(false);
|
||||
} else if (token) {
|
||||
// 长链接解析(异步,带签名验证)
|
||||
const decrypted = await decryptShareParams(token);
|
||||
if (decrypted) {
|
||||
setPlanId(decrypted.planId);
|
||||
@ -134,12 +118,9 @@ export function ImportPlanPage() {
|
||||
if (!shortCode || !shareLinkStatus) return;
|
||||
|
||||
const updateTimer = async () => {
|
||||
const { data } = await supabase
|
||||
.from('share_links')
|
||||
.select('expires_at, status')
|
||||
.eq('short_code', shortCode)
|
||||
.single();
|
||||
|
||||
try {
|
||||
const res = await http.get<any>(`/common/share-links/${shortCode}`);
|
||||
const data = res.data;
|
||||
if (!data || !data.expires_at) return;
|
||||
|
||||
const expiresAt = new Date(data.expires_at);
|
||||
@ -150,10 +131,7 @@ export function ImportPlanPage() {
|
||||
setTimeRemaining('已过期');
|
||||
setLinkExpired(true);
|
||||
if (data.status !== SHARE_LINK_STATUS.EXPIRED && data.status !== SHARE_LINK_STATUS.CONFIRMED && data.status !== SHARE_LINK_STATUS.REJECTED) {
|
||||
await supabase
|
||||
.from('share_links')
|
||||
.update({ status: SHARE_LINK_STATUS.EXPIRED })
|
||||
.eq('short_code', shortCode);
|
||||
await http.put(`/common/share-links/${shortCode}`, { status: SHARE_LINK_STATUS.EXPIRED });
|
||||
setShareLinkStatus(SHARE_LINK_STATUS.EXPIRED as ShareLinkStatus);
|
||||
}
|
||||
} else {
|
||||
@ -161,6 +139,9 @@ export function ImportPlanPage() {
|
||||
const seconds = Math.floor((diff % 60000) / 1000);
|
||||
setTimeRemaining(`${minutes}分${seconds}秒`);
|
||||
}
|
||||
} catch {
|
||||
// ignore timer errors
|
||||
}
|
||||
};
|
||||
|
||||
updateTimer();
|
||||
@ -185,40 +166,42 @@ export function ImportPlanPage() {
|
||||
}, [planId]);
|
||||
|
||||
const fetchPlanData = async () => {
|
||||
// 首先尝试从 production_plans 查询
|
||||
const { data: planData } = await supabase.from('production_plans').select('*').eq('id', planId).maybeSingle();
|
||||
try {
|
||||
const planRes = await http.get<any>(`/common/plans/${planId}`);
|
||||
const planData = planRes.data;
|
||||
|
||||
if (planData) {
|
||||
if (planData && planData.type === 'production') {
|
||||
setPlan(planData);
|
||||
setIsWashingPlan(false);
|
||||
setPurchaserCompanyId(planData.purchaser_id);
|
||||
|
||||
const { data: yarnData } = await supabase.from('yarn_ratios').select('*').eq('plan_id', planId);
|
||||
setYarns(yarnData || []);
|
||||
|
||||
const { data: companyData } = await supabase.from('companies').select('name').eq('id', planData.purchaser_id).maybeSingle();
|
||||
setPurchaserName(companyData?.name || '未知');
|
||||
setYarns(planData.yarn_ratios || []);
|
||||
setPurchaserName(planData.purchaser_name || '未知');
|
||||
|
||||
if (auth.company) {
|
||||
const { data: existing } = await supabase.from('plan_factories').select('id').eq('plan_id', planId).eq('factory_id', auth.company.id).maybeSingle();
|
||||
setAlreadyImported(!!existing);
|
||||
setAlreadyImported(!!planData.already_imported);
|
||||
}
|
||||
} else {
|
||||
// 尝试从 washing_plans 查询
|
||||
const { data: washingPlanData } = await supabase.from('washing_plans').select('*').eq('id', planId).maybeSingle();
|
||||
if (washingPlanData) {
|
||||
setPlan(washingPlanData);
|
||||
} else if (planData && planData.type === 'washing') {
|
||||
setPlan(planData);
|
||||
setIsWashingPlan(true);
|
||||
setPurchaserCompanyId(washingPlanData.company_id);
|
||||
|
||||
const { data: companyData } = await supabase.from('companies').select('name').eq('id', washingPlanData.company_id).maybeSingle();
|
||||
setPurchaserName(companyData?.name || '未知');
|
||||
setPurchaserCompanyId(planData.company_id);
|
||||
setPurchaserName(planData.purchaser_name || '未知');
|
||||
|
||||
if (auth.company) {
|
||||
const { data: existing } = await supabase.from('washing_plans').select('id').eq('id', planId).eq('washing_factory_id', auth.company.id).maybeSingle();
|
||||
setAlreadyImported(!!existing);
|
||||
setAlreadyImported(!!planData.already_imported);
|
||||
}
|
||||
} else if (planData) {
|
||||
setPlan(planData);
|
||||
setIsWashingPlan(false);
|
||||
setPurchaserCompanyId(planData.purchaser_id || planData.company_id);
|
||||
setPurchaserName(planData.purchaser_name || '未知');
|
||||
setYarns(planData.yarn_ratios || []);
|
||||
if (auth.company) {
|
||||
setAlreadyImported(!!planData.already_imported);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// plan not found
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@ -228,72 +211,22 @@ export function ImportPlanPage() {
|
||||
if (!auth.company || !planId || !factoryType || !purchaserCompanyId) return;
|
||||
setImporting(true);
|
||||
|
||||
// 1. 创建 plan_factories 关联
|
||||
if (isWashingPlan) {
|
||||
const { error } = await supabase.from('washing_plans').update({
|
||||
washing_factory_id: auth.company.id
|
||||
}).eq('id', planId);
|
||||
if (error) {
|
||||
console.error('导入失败:', error);
|
||||
setImporting(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const { error } = await supabase.from('plan_factories').insert({
|
||||
plan_id: planId,
|
||||
try {
|
||||
await http.post(`/common/plans/${planId}/confirm`, {
|
||||
factory_id: auth.company.id,
|
||||
factory_type: factoryType
|
||||
});
|
||||
if (error) {
|
||||
console.error('导入失败:', error);
|
||||
setImporting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 1.5 更新计划状态为 producing(生产中)
|
||||
const { error: planUpdateError } = await supabase
|
||||
.from('production_plans')
|
||||
.update({ status: PLAN_STATUS.PRODUCING })
|
||||
.eq('id', planId);
|
||||
|
||||
if (planUpdateError) {
|
||||
handleError(planUpdateError, { userMessage: '更新计划状态失败', context: { planId } });
|
||||
}
|
||||
|
||||
// 2. 创建/更新公司合作关系
|
||||
const { error: relError } = await supabase.from('company_relationships').upsert({
|
||||
purchaser_company_id: purchaserCompanyId,
|
||||
factory_company_id: auth.company.id,
|
||||
factory_type: factoryType,
|
||||
status: 'active'
|
||||
}, {
|
||||
onConflict: 'purchaser_company_id,factory_company_id'
|
||||
is_washing_plan: isWashingPlan,
|
||||
purchaser_company_id: purchaserCompanyId,
|
||||
short_code: shortCode || undefined
|
||||
});
|
||||
|
||||
if (relError) {
|
||||
handleError(relError, { userMessage: '创建合作关系失败', context: { purchaserCompanyId, factoryCompanyId: auth.company.id } });
|
||||
}
|
||||
|
||||
// 3. 更新分享链接状态
|
||||
if (shortCode) {
|
||||
const { error: linkUpdateError } = await supabase
|
||||
.from('share_links')
|
||||
.update({
|
||||
status: SHARE_LINK_STATUS.CONFIRMED,
|
||||
responded_at: new Date().toISOString(),
|
||||
response_result: 'confirmed'
|
||||
})
|
||||
.eq('short_code', shortCode);
|
||||
|
||||
if (linkUpdateError) {
|
||||
handleError(linkUpdateError, { userMessage: '更新分享链接状态失败', context: { shortCode } });
|
||||
}
|
||||
}
|
||||
|
||||
setImportSuccess(true);
|
||||
setImporting(false);
|
||||
setTimeout(() => { navigate(`/${auth.currentRole}/plans`); }, 2000);
|
||||
} catch (err) {
|
||||
handleError(err, { userMessage: '确认失败' });
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 拒绝计划
|
||||
@ -302,18 +235,18 @@ export function ImportPlanPage() {
|
||||
|
||||
setImporting(true);
|
||||
|
||||
await supabase
|
||||
.from('share_links')
|
||||
.update({
|
||||
try {
|
||||
await http.put(`/common/share-links/${shortCode}`, {
|
||||
status: SHARE_LINK_STATUS.REJECTED,
|
||||
responded_at: new Date().toISOString(),
|
||||
response_result: 'rejected',
|
||||
reject_reason: rejectReason
|
||||
})
|
||||
.eq('short_code', shortCode);
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
setImporting(false);
|
||||
// 显示拒绝成功,返回首页
|
||||
setTimeout(() => { navigate('/role-select'); }, 2000);
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { commonApi, http } from '../api';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Users, ArrowLeft, Plus, X, User, Phone, Shield, Trash2, AlertCircle, Edit2, Building2, BookOpen, MessageSquare, Info } from 'lucide-react';
|
||||
import { useResponsive } from '../hooks/useResponsive';
|
||||
@ -70,39 +70,31 @@ export function MemberManage() {
|
||||
|
||||
const fetchCompanyInfo = async () => {
|
||||
if (!auth.user?.company_id) return;
|
||||
const { data } = await supabase
|
||||
.from('companies')
|
||||
.select('name, contact_phone, role')
|
||||
.eq('id', auth.user.company_id)
|
||||
.maybeSingle();
|
||||
try {
|
||||
const data = await commonApi.getCompany(auth.user.company_id);
|
||||
if (data) {
|
||||
setCompanyInfo(data);
|
||||
setCompanyInfo(data as any);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMembers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 获取当前公司的所有子账号(非主账号)
|
||||
const companyId = auth.user?.company_id;
|
||||
if (!companyId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, username, phone, created_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_master', false)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('获取成员列表失败:', error);
|
||||
} else {
|
||||
setMembers(data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取成员列表出错:', err);
|
||||
const data = await commonApi.listMembers();
|
||||
const subMembers = (data || [])
|
||||
.filter((m: any) => !m.profile?.is_master)
|
||||
.map((m: any) => ({
|
||||
id: m.profile?.id || m.id,
|
||||
username: m.profile?.username || '',
|
||||
phone: m.profile?.phone || null,
|
||||
created_at: m.profile?.created_at || m.created_at
|
||||
}));
|
||||
setMembers(subMembers);
|
||||
} catch {
|
||||
setMembers([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@ -146,39 +138,20 @@ export function MemberManage() {
|
||||
|
||||
setEditingMaster(true);
|
||||
try {
|
||||
// 更新姓名和手机号
|
||||
const { error: updateError } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
await http.put('/common/profile', {
|
||||
display_name: masterFormData.displayName,
|
||||
phone: encryptPhone(masterFormData.phone)
|
||||
})
|
||||
.eq('id', auth.user?.id || '');
|
||||
|
||||
if (updateError) {
|
||||
setError('更新失败:' + updateError.message);
|
||||
setEditingMaster(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果填写了密码,更新密码
|
||||
if (masterFormData.password && masterFormData.password.length >= 6) {
|
||||
const { error: passwordError } = await supabase.auth.updateUser({
|
||||
password: masterFormData.password
|
||||
});
|
||||
|
||||
if (passwordError) {
|
||||
setError('密码更新失败:' + passwordError.message);
|
||||
setEditingMaster(false);
|
||||
return;
|
||||
}
|
||||
if (masterFormData.password && masterFormData.password.length >= 6) {
|
||||
await http.put('/common/password', {
|
||||
password: masterFormData.password
|
||||
});
|
||||
}
|
||||
|
||||
// 刷新页面以获取更新后的用户信息
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
setError('更新时发生错误');
|
||||
console.error(err);
|
||||
} catch (err: any) {
|
||||
setError('更新失败:' + (err.message || ''));
|
||||
}
|
||||
setEditingMaster(false);
|
||||
};
|
||||
@ -213,74 +186,17 @@ export function MemberManage() {
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// 检查用户名是否已存在(全局唯一性检查)
|
||||
const { data: existingUser, error: checkError } = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.eq('username', formData.username.trim())
|
||||
.maybeSingle();
|
||||
|
||||
if (checkError) {
|
||||
console.error('检查用户名失败:', checkError);
|
||||
}
|
||||
|
||||
if (existingUser) {
|
||||
setError('该用户名已被使用,请更换其他用户名');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
// 1. 创建用户(使用虚拟邮箱格式)
|
||||
const email = `${formData.username}@meoo.local`;
|
||||
const { data: authData, error: signUpError } = await supabase.auth.signUp({
|
||||
email,
|
||||
await commonApi.createMember({
|
||||
username: formData.username.trim(),
|
||||
password: formData.password,
|
||||
options: {
|
||||
data: {
|
||||
username: formData.username,
|
||||
phone: formData.phone || null,
|
||||
company_id: auth.user.company_id,
|
||||
is_master: false,
|
||||
master_id: auth.user.id
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (signUpError) {
|
||||
setError('创建账号失败:' + signUpError.message);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authData.user) {
|
||||
setError('创建账号失败,请重试');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 创建 profile 记录(手机号加密存储)
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.insert({
|
||||
id: authData.user.id,
|
||||
username: formData.username,
|
||||
display_name: formData.displayName,
|
||||
phone: encryptPhone(formData.phone) || null,
|
||||
company_id: auth.user.company_id,
|
||||
is_master: false,
|
||||
master_id: auth.user.id
|
||||
phone: formData.phone || undefined
|
||||
});
|
||||
|
||||
if (profileError) {
|
||||
console.error('创建 profile 失败:', profileError);
|
||||
// 继续执行,因为用户已创建成功
|
||||
}
|
||||
|
||||
// 3. 刷新成员列表
|
||||
await fetchMembers();
|
||||
handleCloseModal();
|
||||
} catch (err) {
|
||||
setError('创建子账号时发生错误');
|
||||
console.error(err);
|
||||
} catch (err: any) {
|
||||
setError('创建子账号失败:' + (err.message || ''));
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
@ -288,22 +204,11 @@ export function MemberManage() {
|
||||
|
||||
const handleDelete = async (memberId: string) => {
|
||||
try {
|
||||
// 删除 profile 记录
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.delete()
|
||||
.eq('id', memberId);
|
||||
|
||||
if (profileError) {
|
||||
console.error('删除成员失败:', profileError);
|
||||
return;
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
await commonApi.deleteMember(memberId);
|
||||
setMembers(members.filter(m => m.id !== memberId));
|
||||
setShowDeleteConfirm(null);
|
||||
} catch (err) {
|
||||
console.error('删除成员出错:', err);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { purchaserApi, http } from '../../api';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, DollarSign, Package, CheckCircle, Clock, AlertCircle } from 'lucide-react';
|
||||
|
||||
@ -53,67 +53,31 @@ export function AccountsPayable() {
|
||||
const fetchPayables = async () => {
|
||||
setLoading(true);
|
||||
|
||||
// 获取应付账款列表
|
||||
const { data: apData, error: apError } = await supabase
|
||||
.from('accounts_payable')
|
||||
.select('*')
|
||||
.eq('purchaser_id', auth.company!.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (apError) {
|
||||
console.error('获取应付账款失败:', apError);
|
||||
setLoading(false);
|
||||
return;
|
||||
try {
|
||||
const res = await purchaserApi.listAccountsPayable();
|
||||
setPayables((res.data || []) as AccountsPayable[]);
|
||||
} catch {
|
||||
setPayables([]);
|
||||
}
|
||||
|
||||
// 获取工厂信息
|
||||
const factoryIds = [...new Set((apData || []).map(ap => ap.textile_factory_id))];
|
||||
const { data: factoriesData } = await supabase
|
||||
.from('companies')
|
||||
.select('id, name')
|
||||
.in('id', factoryIds);
|
||||
|
||||
const factoryMap: Record<string, string> = {};
|
||||
(factoriesData || []).forEach(f => {
|
||||
factoryMap[f.id] = f.name;
|
||||
});
|
||||
|
||||
// 获取计划信息
|
||||
const planIds = [...new Set((apData || []).map(ap => ap.plan_id))];
|
||||
const { data: plansData } = await supabase
|
||||
.from('production_plans')
|
||||
.select('id, plan_code, product_name, color')
|
||||
.in('id', planIds);
|
||||
|
||||
const planMap: Record<string, { plan_code: string; product_name: string; color: string }> = {};
|
||||
(plansData || []).forEach(p => {
|
||||
planMap[p.id] = { plan_code: p.plan_code, product_name: p.product_name, color: p.color };
|
||||
});
|
||||
|
||||
// 组合数据
|
||||
const payablesWithInfo: AccountsPayable[] = (apData || []).map(ap => ({
|
||||
...ap,
|
||||
factory_name: factoryMap[ap.textile_factory_id] || '未知工厂',
|
||||
plan_code: planMap[ap.plan_id]?.plan_code || '',
|
||||
product_name: planMap[ap.plan_id]?.product_name || '',
|
||||
color: planMap[ap.plan_id]?.color || ''
|
||||
}));
|
||||
|
||||
setPayables(payablesWithInfo);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchPayableItems = async (payableId: string) => {
|
||||
const { data: itemsData } = await supabase
|
||||
.from('accounts_payable_items')
|
||||
.select('*')
|
||||
.eq('accounts_payable_id', payableId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
try {
|
||||
const res = await http.get<AccountsPayableItem[]>(
|
||||
`/purchaser/accounts-payable/${payableId}/items`
|
||||
);
|
||||
setPayableItems(prev => ({
|
||||
...prev,
|
||||
[payableId]: itemsData || []
|
||||
[payableId]: res.data || []
|
||||
}));
|
||||
} catch {
|
||||
setPayableItems(prev => ({
|
||||
...prev,
|
||||
[payableId]: []
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExpand = (payableId: string) => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { purchaserApi } from '../../api';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
FileText,
|
||||
@ -84,28 +84,12 @@ export function PurchaserDashboard() {
|
||||
}, [auth.company, dataLoaded, plans.length]);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
const { data: planData } = await supabase
|
||||
.from('production_plans')
|
||||
.select('*')
|
||||
.eq('purchaser_id', auth.company!.id)
|
||||
.order('created_at', { ascending: false });
|
||||
const res = await purchaserApi.listPlans();
|
||||
const allPlans = res.data.plans;
|
||||
|
||||
// 获取流程步骤,检测 rejected 状态
|
||||
const planIds = (planData || []).map(p => p.id);
|
||||
const { data: stepsData } = await supabase
|
||||
.from('plan_process_steps')
|
||||
.select('plan_id, status')
|
||||
.in('plan_id', planIds);
|
||||
|
||||
// 过滤掉有 rejected 步骤的计划
|
||||
const rejectedPlanIds = new Set<string>();
|
||||
(stepsData || []).forEach(step => {
|
||||
if (step.status === STEP_STATUS.REJECTED) {
|
||||
rejectedPlanIds.add(step.plan_id);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredPlans = (planData || []).filter(p => !rejectedPlanIds.has(p.id));
|
||||
const filteredPlans = allPlans.filter(p =>
|
||||
!p.process_steps?.some(s => s.status === STEP_STATUS.REJECTED)
|
||||
);
|
||||
setPlans(filteredPlans);
|
||||
setLoading(false);
|
||||
setDataLoaded(true);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { http } from '../../api';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Factory, Droplets, FileText, ChevronRight, ArrowLeft, Plus, X, Building2, Phone, MapPin, User, Edit2, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
@ -43,32 +43,24 @@ export function FactoryManage() {
|
||||
|
||||
const fetchFactories = async () => {
|
||||
setLoading(true);
|
||||
// 直接查询所有该类型的工厂(不再通过 plan_factories 关联)
|
||||
const { data: factoryData } = await supabase
|
||||
.from('companies')
|
||||
.select('*')
|
||||
.eq('role', activeTab)
|
||||
.order('created_at', { ascending: false });
|
||||
try {
|
||||
const res = await http.get<Company[]>('/purchaser/factories', { role: activeTab });
|
||||
setFactories(res.data || []);
|
||||
|
||||
setFactories(factoryData || []);
|
||||
|
||||
// 计算每个工厂的计划数(通过 plan_factories 统计)
|
||||
if (factoryData && factoryData.length > 0) {
|
||||
const factoryIds = factoryData.map(f => f.id);
|
||||
const { data: planFactories } = await supabase
|
||||
.from('plan_factories')
|
||||
.select('factory_id')
|
||||
.in('factory_id', factoryIds)
|
||||
.eq('factory_type', activeTab);
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
planFactories?.forEach(pf => {
|
||||
counts[pf.factory_id] = (counts[pf.factory_id] || 0) + 1;
|
||||
if (res.data && res.data.length > 0) {
|
||||
const factoryIds = res.data.map(f => f.id);
|
||||
const countsRes = await http.get<Record<string, number>>('/purchaser/factories/plan-counts', {
|
||||
factory_ids: factoryIds.join(','),
|
||||
factory_type: activeTab
|
||||
});
|
||||
setPlanCounts(counts);
|
||||
setPlanCounts(countsRes.data || {});
|
||||
} else {
|
||||
setPlanCounts({});
|
||||
}
|
||||
} catch {
|
||||
setFactories([]);
|
||||
setPlanCounts({});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@ -116,67 +108,14 @@ export function FactoryManage() {
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
console.log('开始删除工厂:', deletingFactory.id, deletingFactory.name);
|
||||
|
||||
// 检查该工厂是否有关联的计划
|
||||
const { data: planFactories, error: checkError } = await supabase
|
||||
.from('plan_factories')
|
||||
.select('id')
|
||||
.eq('factory_id', deletingFactory.id)
|
||||
.limit(1);
|
||||
|
||||
if (checkError) {
|
||||
handleError(checkError, { userMessage: '检查关联计划失败' });
|
||||
setDeleting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (planFactories && planFactories.length > 0) {
|
||||
handleError(new Error('无法删除'), { userMessage: '该工厂已有关联计划,无法删除' });
|
||||
setDeleting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('准备删除工厂,ID:', deletingFactory.id);
|
||||
|
||||
// 先删除关联的合作关系记录
|
||||
const { error: relError } = await supabase
|
||||
.from('company_relationships')
|
||||
.delete()
|
||||
.eq('factory_company_id', deletingFactory.id);
|
||||
|
||||
if (relError) {
|
||||
handleError(relError, { userMessage: '删除合作关系失败' });
|
||||
setDeleting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('已删除关联的合作关系');
|
||||
|
||||
// 删除工厂
|
||||
const { data: deleteData, error: deleteError } = await supabase
|
||||
.from('companies')
|
||||
.delete()
|
||||
.eq('id', deletingFactory.id)
|
||||
.select();
|
||||
|
||||
if (deleteError) {
|
||||
handleError(deleteError, { userMessage: '删除工厂失败' });
|
||||
setDeleting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('删除成功:', deleteData);
|
||||
|
||||
// 刷新列表
|
||||
await http.delete(`/purchaser/factories/${deletingFactory.id}`);
|
||||
await fetchFactories();
|
||||
// 如果删除的是当前展开的工厂,关闭展开状态
|
||||
if (expandedId === deletingFactory.id) {
|
||||
setExpandedId(null);
|
||||
}
|
||||
handleCloseDeleteModal();
|
||||
} catch (err) {
|
||||
handleError(err, { userMessage: '删除工厂时发生错误' });
|
||||
handleError(err, { userMessage: '删除工厂失败' });
|
||||
}
|
||||
setDeleting(false);
|
||||
};
|
||||
@ -189,26 +128,16 @@ export function FactoryManage() {
|
||||
|
||||
// 检查工厂名称是否已存在(同一类型下)
|
||||
const checkDuplicateName = async (name: string, excludeId?: string): Promise<boolean> => {
|
||||
const { data, error } = await supabase
|
||||
.from('companies')
|
||||
.select('id')
|
||||
.eq('role', activeTab)
|
||||
.ilike('name', name.trim())
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
console.error('检查重名失败:', error);
|
||||
try {
|
||||
const res = await http.get<{ exists: boolean }>('/purchaser/factories/check-name', {
|
||||
role: activeTab,
|
||||
name: name.trim(),
|
||||
exclude_id: excludeId
|
||||
});
|
||||
return res.data.exists;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果存在记录且不是当前编辑的工厂,则认为是重名
|
||||
if (data && data.length > 0) {
|
||||
if (excludeId && data[0].id === excludeId) {
|
||||
return false; // 是自己,不算重名
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@ -228,7 +157,6 @@ export function FactoryManage() {
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// 检查重名
|
||||
const isDuplicate = await checkDuplicateName(formData.name, editingFactory?.id);
|
||||
if (isDuplicate) {
|
||||
setError(`该${activeTab === 'textile' ? '织布厂' : '水洗厂'}名称已存在,请使用其他名称`);
|
||||
@ -237,48 +165,24 @@ export function FactoryManage() {
|
||||
}
|
||||
|
||||
if (editingFactory) {
|
||||
// 更新工厂信息
|
||||
const { error: updateError } = await supabase
|
||||
.from('companies')
|
||||
.update({
|
||||
await http.put(`/purchaser/factories/${editingFactory.id}`, {
|
||||
name: formData.name,
|
||||
contact_phone: formData.contactPhone,
|
||||
address: formData.address
|
||||
})
|
||||
.eq('id', editingFactory.id);
|
||||
|
||||
if (updateError) {
|
||||
setError('更新工厂失败:' + updateError.message);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 创建工厂(作为公司)
|
||||
const { data: newFactory, error: factoryError } = await supabase
|
||||
.from('companies')
|
||||
.insert({
|
||||
await http.post('/purchaser/factories', {
|
||||
name: formData.name,
|
||||
role: activeTab,
|
||||
contact_phone: formData.contactPhone,
|
||||
address: formData.address
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (factoryError || !newFactory) {
|
||||
setError('创建工厂失败:' + factoryError?.message);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
// 工厂创建成功,直接显示在列表中(不再需要通过 plan_factories 关联)
|
||||
});
|
||||
}
|
||||
|
||||
// 刷新工厂列表
|
||||
await fetchFactories();
|
||||
handleCloseModal();
|
||||
} catch (err) {
|
||||
setError(editingFactory ? '更新工厂时发生错误' : '创建工厂时发生错误');
|
||||
console.error(err);
|
||||
} catch (err: any) {
|
||||
setError(editingFactory ? '更新工厂失败:' + (err.message || '') : '创建工厂失败:' + (err.message || ''));
|
||||
}
|
||||
|
||||
setSubmitting(false);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { http } from '../../api';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowLeft, Package, Search, History, ArrowUpRight, ArrowDownRight, Boxes, Shirt } from 'lucide-react';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
@ -37,36 +37,23 @@ export function FinishedWarehouse() {
|
||||
|
||||
const fetchProducts = async () => {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('finished_products')
|
||||
.select(`
|
||||
*,
|
||||
warehouse:warehouse_id(name)
|
||||
`)
|
||||
.eq('company_id', auth.company!.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('获取成品失败:', error);
|
||||
} else {
|
||||
const productsWithDetails = (data || []).map(product => ({
|
||||
...product,
|
||||
warehouse_name: (product.warehouse as any)?.name
|
||||
}));
|
||||
setProducts(productsWithDetails);
|
||||
try {
|
||||
const res = await http.get<FinishedProductWithDetails[]>('/purchaser/finished-products');
|
||||
setProducts(res.data || []);
|
||||
} catch {
|
||||
setProducts([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchInventoryRecords = async (productId: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('finished_product_inventory_records')
|
||||
.select('*')
|
||||
.eq('finished_product_id', productId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!error) {
|
||||
setInventoryRecords(data || []);
|
||||
try {
|
||||
const res = await http.get<FinishedProductInventoryRecord[]>(
|
||||
`/purchaser/finished-products/${productId}/records`
|
||||
);
|
||||
setInventoryRecords(res.data || []);
|
||||
} catch {
|
||||
setInventoryRecords([]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { purchaserApi } from '../../api';
|
||||
import { http } from '../../api';
|
||||
import { ArrowLeft, Plus, Trash2, Search, X, ChevronDown } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useErrorHandler } from '../../hooks/useErrorHandler';
|
||||
import type { Company, FactoryType } from '../../types';
|
||||
import { PLAN_STATUS, STEP_STATUS } from '../../utils/constants';
|
||||
|
||||
interface YarnInput {
|
||||
id: string;
|
||||
@ -88,26 +88,21 @@ export function NewPlan() {
|
||||
if (!auth.company) return;
|
||||
setLoading(true);
|
||||
|
||||
const { data: prodData } = await supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.eq('company_id', auth.company.id)
|
||||
.order('created_at', { ascending: false });
|
||||
const res = await purchaserApi.listProducts();
|
||||
const prodData = res.data;
|
||||
|
||||
if (prodData) {
|
||||
setProducts(prodData as Product[]);
|
||||
|
||||
// 获取每个产品的纱线配比
|
||||
const productIds = prodData.map(p => p.id);
|
||||
if (productIds.length > 0) {
|
||||
const { data: ratiosData } = await supabase
|
||||
.from('product_yarn_ratios')
|
||||
.select('*')
|
||||
.in('product_id', productIds);
|
||||
const ratiosRes = await http.get<ProductYarnRatio[]>('/purchaser/products/yarn-ratios', {
|
||||
product_ids: productIds.join(',')
|
||||
});
|
||||
|
||||
if (ratiosData) {
|
||||
if (ratiosRes.data) {
|
||||
const ratiosMap: Record<string, ProductYarnRatio[]> = {};
|
||||
ratiosData.forEach((r: any) => {
|
||||
ratiosRes.data.forEach((r: any) => {
|
||||
if (!ratiosMap[r.product_id]) ratiosMap[r.product_id] = [];
|
||||
ratiosMap[r.product_id].push(r as ProductYarnRatio);
|
||||
});
|
||||
@ -120,14 +115,7 @@ export function NewPlan() {
|
||||
|
||||
const fetchFactories = async () => {
|
||||
if (!auth.company) return;
|
||||
|
||||
// 查询工厂管理中登记的纺织厂(companies表中role=textile的记录)
|
||||
const { data } = await supabase
|
||||
.from('companies')
|
||||
.select('*')
|
||||
.eq('role', 'textile')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
const data = await purchaserApi.listFactories();
|
||||
setFactories(data || []);
|
||||
};
|
||||
|
||||
@ -199,97 +187,21 @@ export function NewPlan() {
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
// 生成计划编号
|
||||
const timestamp = Date.now().toString().slice(-6);
|
||||
const planCode = `PLAN-2026-${timestamp}`;
|
||||
|
||||
// 创建计划
|
||||
const { data: plan, error: planError } = await supabase
|
||||
.from('production_plans')
|
||||
.insert({
|
||||
plan_code: planCode,
|
||||
await purchaserApi.createPlan({
|
||||
product_name: productName,
|
||||
color: color,
|
||||
fabric_code: fabricCode.toUpperCase(),
|
||||
color_code: colorCode,
|
||||
remark: remark,
|
||||
purchaser_id: auth.company.id,
|
||||
color: color,
|
||||
target_quantity: parseInt(targetQuantity) || 0,
|
||||
yarn_usage_per_meter: selectedProduct?.weight || 0, // 使用产品克重作为纱用量
|
||||
production_price: parseFloat(productionPrice) || 0,
|
||||
status: PLAN_STATUS.PENDING,
|
||||
created_by: auth.user?.id
|
||||
})
|
||||
.select()
|
||||
.maybeSingle();
|
||||
|
||||
if (planError) {
|
||||
handleError(planError, { userMessage: '创建计划失败' });
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan) {
|
||||
// 创建纱线配比
|
||||
const yarnErrors: string[] = [];
|
||||
for (const yarn of yarns) {
|
||||
if (yarn.name) {
|
||||
const amountPerMeter = selectedProduct?.weight || 0;
|
||||
const totalAmount = amountPerMeter * yarn.ratio * (parseInt(targetQuantity) || 0);
|
||||
const { error: yarnError } = await supabase.from('yarn_ratios').insert({
|
||||
plan_id: plan.id,
|
||||
unit_price: parseFloat(productionPrice) || 0,
|
||||
notes: remark,
|
||||
yarn_ratios: yarns.filter(y => y.name).map(yarn => ({
|
||||
yarn_name: yarn.name,
|
||||
ratio: yarn.ratio,
|
||||
amount_per_meter: amountPerMeter * yarn.ratio,
|
||||
total_amount: totalAmount
|
||||
amount_per_meter: (selectedProduct?.weight || 0) * yarn.ratio,
|
||||
yarn_type: yarn.yarn_type
|
||||
}))
|
||||
});
|
||||
if (yarnError) {
|
||||
yarnErrors.push(yarnError.message);
|
||||
console.error('纱线配比创建失败:', yarnError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建流程节点
|
||||
const stepErrors: string[] = [];
|
||||
const steps: ('confirm' | 'yarn_purchase' | 'dyeing' | 'machine_start' | 'fabric_warehouse')[] = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse'];
|
||||
for (const step of steps) {
|
||||
const { error: stepError } = await supabase.from('plan_process_steps').insert({
|
||||
plan_id: plan.id,
|
||||
step_type: step,
|
||||
status: STEP_STATUS.PENDING
|
||||
});
|
||||
if (stepError) {
|
||||
stepErrors.push(stepError.message);
|
||||
console.error('流程节点创建失败:', stepError);
|
||||
}
|
||||
}
|
||||
|
||||
// 记录初始价格(旧价格为0,表示新建)
|
||||
const finalPrice = parseFloat(productionPrice) || 0;
|
||||
if (finalPrice > 0) {
|
||||
const { error: priceError } = await supabase.from('production_price_history').insert({
|
||||
plan_id: plan.id,
|
||||
old_price: 0,
|
||||
new_price: finalPrice,
|
||||
change_amount: finalPrice,
|
||||
operator_id: auth.user?.id,
|
||||
operator_name: auth.user?.username || '',
|
||||
change_reason: '创建计划时设置初始价格'
|
||||
});
|
||||
if (priceError) {
|
||||
console.error('价格历史记录失败:', priceError);
|
||||
}
|
||||
}
|
||||
|
||||
// 注意:工厂关联不再在创建计划时自动进行
|
||||
// 而是通过分享链接或推送功能,由工厂确认后才建立关联
|
||||
|
||||
// 如果有子操作失败,提示用户但允许继续
|
||||
if (yarnErrors.length > 0 || stepErrors.length > 0) {
|
||||
console.warn('计划创建完成,但部分数据创建失败:', { yarnErrors, stepErrors });
|
||||
}
|
||||
}
|
||||
|
||||
alert('计划创建成功!');
|
||||
navigate('/purchaser/plans');
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { purchaserApi, http } from '../../api';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, Plus, Droplets, Calculator, AlertCircle } from 'lucide-react';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
import type { ProductWithInventory, Company } from '../../types';
|
||||
import { WASHING_PLAN_STATUS } from '../../utils/constants';
|
||||
|
||||
interface WashingPlanForm {
|
||||
productId: string;
|
||||
@ -41,84 +40,31 @@ export function NewWashingPlan() {
|
||||
const fetchProducts = useCallback(async () => {
|
||||
if (!auth.company) return;
|
||||
|
||||
// 获取产品列表
|
||||
const { data: productsData } = await supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.eq('company_id', auth.company.id)
|
||||
.order('created_at', { ascending: false });
|
||||
const res = await purchaserApi.listProducts();
|
||||
const productsData = res.data || [];
|
||||
|
||||
// 获取生产计划(关联到产品)
|
||||
// 注意:使用 company_id 查询,与 RLS 策略保持一致
|
||||
const fabricCodes = (productsData || []).map(p => p.fabric_code);
|
||||
const { data: plansData, error: plansError } = await supabase
|
||||
.from('production_plans')
|
||||
.select('id, fabric_code, purchaser_id')
|
||||
.in('fabric_code', fabricCodes);
|
||||
|
||||
if (plansError) {
|
||||
console.error('获取生产计划失败:', plansError);
|
||||
}
|
||||
|
||||
// 过滤出当前采购商的计划(客户端过滤,避免 RLS 问题)
|
||||
const filteredPlansData = (plansData || []).filter(plan => plan.purchaser_id === auth.company?.id);
|
||||
|
||||
// 获取入库记录(用于计算实际米数和匹数)
|
||||
const planIds = (filteredPlansData || []).map(p => p.id);
|
||||
const { data: inventoryRecords } = await supabase
|
||||
.from('inventory_records')
|
||||
.select('plan_id, quantity, rolls')
|
||||
.in('plan_id', planIds);
|
||||
|
||||
// 按 plan_id 汇总实际入库数据
|
||||
const planInventoryMap: Record<string, { meters: number; rolls: number }> = {};
|
||||
(inventoryRecords || []).forEach((r: any) => {
|
||||
if (!planInventoryMap[r.plan_id]) {
|
||||
planInventoryMap[r.plan_id] = { meters: 0, rolls: 0 };
|
||||
}
|
||||
planInventoryMap[r.plan_id].meters += Number(r.quantity) || 0;
|
||||
planInventoryMap[r.plan_id].rolls += Number(r.rolls) || 0;
|
||||
});
|
||||
|
||||
// 按 fabric_code 汇总库存
|
||||
const fabricCodeInventory: Record<string, { meters: number; rolls: number }> = {};
|
||||
|
||||
(filteredPlansData || []).forEach((plan: any) => {
|
||||
if (!fabricCodeInventory[plan.fabric_code]) {
|
||||
fabricCodeInventory[plan.fabric_code] = { meters: 0, rolls: 0 };
|
||||
}
|
||||
const planInventory = planInventoryMap[plan.id] || { meters: 0, rolls: 0 };
|
||||
fabricCodeInventory[plan.fabric_code].meters += planInventory.meters;
|
||||
fabricCodeInventory[plan.fabric_code].rolls += planInventory.rolls;
|
||||
});
|
||||
|
||||
// 计算每个产品的库存
|
||||
const productsWithInventory: ProductWithInventory[] = (productsData || []).map(product => {
|
||||
const inventory = fabricCodeInventory[product.fabric_code] || { meters: 0, rolls: 0 };
|
||||
|
||||
return {
|
||||
const productsWithInventory: ProductWithInventory[] = productsData.map(product => ({
|
||||
...product,
|
||||
raw_fabric_meters: inventory.meters,
|
||||
raw_fabric_rolls: inventory.rolls,
|
||||
total_stock: inventory.meters,
|
||||
total_in_meters: inventory.meters,
|
||||
total_in_rolls: inventory.rolls,
|
||||
raw_fabric_meters: product.raw_fabric_meters || 0,
|
||||
raw_fabric_rolls: product.raw_fabric_rolls || 0,
|
||||
total_stock: product.total_stock || 0,
|
||||
total_in_meters: 0,
|
||||
total_in_rolls: 0,
|
||||
total_out_meters: 0,
|
||||
total_out_rolls: 0
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
setProducts(productsWithInventory);
|
||||
}, [auth.company]);
|
||||
|
||||
// 获取水洗厂列表
|
||||
const fetchWashingFactories = useCallback(async () => {
|
||||
const { data } = await supabase
|
||||
.from('companies')
|
||||
.select('*')
|
||||
.eq('role', 'washing')
|
||||
.order('name', { ascending: true });
|
||||
setWashingFactories(data || []);
|
||||
try {
|
||||
const res = await http.get<Company[]>('/purchaser/factories', { role: 'washing' });
|
||||
setWashingFactories(res.data || []);
|
||||
} catch {
|
||||
setWashingFactories([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@ -185,68 +131,22 @@ export function NewWashingPlan() {
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
// 生成计划编号
|
||||
const planCode = generatePlanCode();
|
||||
|
||||
// 1. 创建水洗计划
|
||||
const { data: washingPlan, error: insertError } = await supabase.from('washing_plans').insert({
|
||||
plan_code: planCode,
|
||||
company_id: auth.company!.id,
|
||||
try {
|
||||
await purchaserApi.createWashingPlan({
|
||||
product_id: form.productId,
|
||||
washing_factory_id: form.washingFactoryId,
|
||||
planned_meters: plannedMeters,
|
||||
planned_meters: parseFloat(form.plannedMeters),
|
||||
washing_price: parseFloat(form.washingPrice),
|
||||
estimated_shrinkage_rate: parseFloat(form.estimatedShrinkageRate) || 0,
|
||||
notes: form.notes || null,
|
||||
created_by: auth.user?.id,
|
||||
status: WASHING_PLAN_STATUS.PENDING
|
||||
}).select().single();
|
||||
|
||||
if (insertError) {
|
||||
setSubmitting(false);
|
||||
setError('创建失败: ' + insertError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 自动出库 - 创建出库记录
|
||||
// 计算需要出库的匹数(按比例计算)
|
||||
const stockMeters = selectedProduct.total_stock || 0;
|
||||
const stockRolls = selectedProduct.raw_fabric_rolls || 0;
|
||||
const outboundRolls = stockMeters > 0 ? Math.ceil((plannedMeters / stockMeters) * stockRolls) : 0;
|
||||
|
||||
const { error: outboundError } = await supabase.from('product_outbound_records').insert({
|
||||
company_id: auth.company!.id,
|
||||
product_id: form.productId,
|
||||
batch_no: `WS-${planCode}`,
|
||||
rolls: outboundRolls,
|
||||
meters: plannedMeters,
|
||||
notes: `水洗计划自动出库: ${planCode}`,
|
||||
operator_id: auth.user?.id || null,
|
||||
washing_plan_id: washingPlan.id,
|
||||
outbound_type: 'auto_washing'
|
||||
});
|
||||
|
||||
if (outboundError) {
|
||||
setSubmitting(false);
|
||||
setError('出库记录创建失败: ' + outboundError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 更新产品库存
|
||||
const { error: updateError } = await supabase.from('products').update({
|
||||
raw_fabric_rolls: Math.max(0, (selectedProduct.raw_fabric_rolls || 0) - outboundRolls),
|
||||
raw_fabric_meters: Math.max(0, (selectedProduct.raw_fabric_meters || 0) - plannedMeters),
|
||||
total_stock: Math.max(0, (selectedProduct.total_stock || 0) - plannedMeters)
|
||||
}).eq('id', form.productId);
|
||||
|
||||
if (updateError) {
|
||||
setSubmitting(false);
|
||||
setError('库存更新失败: ' + updateError.message);
|
||||
return;
|
||||
}
|
||||
notes: form.notes || undefined
|
||||
} as any);
|
||||
|
||||
setSubmitting(false);
|
||||
navigate('/purchaser');
|
||||
} catch (err: any) {
|
||||
setSubmitting(false);
|
||||
setError('创建失败: ' + (err.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const selectedProduct = products.find(p => p.id === form.productId);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { purchaserApi, http } from '../../api';
|
||||
import { useInventoryData } from '../../hooks/useInventoryData';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
import { useErrorHandler } from '../../hooks/useErrorHandler';
|
||||
@ -158,7 +158,6 @@ export function WarehouseManage() {
|
||||
if (!auth.company) return;
|
||||
|
||||
const productData = {
|
||||
company_id: auth.company.id,
|
||||
product_name: formData.product_name,
|
||||
weight: parseFloat(formData.total_weight) || parseFloat(formData.weight) || 0,
|
||||
warp_weight: parseFloat(formData.warp_weight) || null,
|
||||
@ -171,116 +170,40 @@ export function WarehouseManage() {
|
||||
image_url: formData.image_url || null
|
||||
};
|
||||
|
||||
// 校验1:产品码全局唯一
|
||||
const { data: existingFabricCode } = await supabase
|
||||
.from('products')
|
||||
.select('id, product_name, color')
|
||||
.eq('company_id', auth.company.id)
|
||||
.eq('fabric_code', productData.fabric_code)
|
||||
.neq('id', editingProduct?.id || '00000000-0000-0000-0000-000000000000')
|
||||
.maybeSingle();
|
||||
|
||||
if (existingFabricCode) {
|
||||
handleError(new Error('产品码重复'), { userMessage: `产品码 "${productData.fabric_code}" 已存在,请使用其他产品码` });
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验2:色号在"成品名称-克重-颜色"组合中唯一
|
||||
// 即:同一个"成品名称-克重-颜色"下,色号不能重复
|
||||
const { data: existingColorCode } = await supabase
|
||||
.from('products')
|
||||
.select('id, fabric_code')
|
||||
.eq('company_id', auth.company.id)
|
||||
.eq('product_name', productData.product_name)
|
||||
.eq('total_weight', productData.total_weight || 0)
|
||||
.eq('color', productData.color)
|
||||
.eq('color_code', productData.color_code)
|
||||
.neq('id', editingProduct?.id || '00000000-0000-0000-0000-000000000000')
|
||||
.maybeSingle();
|
||||
|
||||
if (existingColorCode) {
|
||||
handleError(new Error('色号重复'), { userMessage: `"${productData.product_name}-${productData.total_weight}g-${productData.color}" 组合中已存在色号 "${productData.color_code}",请使用其他色号` });
|
||||
return;
|
||||
}
|
||||
|
||||
let productId: string;
|
||||
const yarnRatios = formData.yarn_ratios.filter(r => r.yarn_name && r.ratio > 0);
|
||||
|
||||
try {
|
||||
if (editingProduct) {
|
||||
// 更新产品
|
||||
const oldPrice = editingProduct.production_price || 0;
|
||||
const newPrice = parseFloat(formData.production_price) || 0;
|
||||
|
||||
const { error } = await supabase
|
||||
.from('products')
|
||||
.update(productData)
|
||||
.eq('id', editingProduct.id);
|
||||
|
||||
if (error) {
|
||||
handleError(error, { userMessage: '更新失败' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录价格变更
|
||||
if (oldPrice !== newPrice) {
|
||||
await supabase.from('product_price_history').insert({
|
||||
product_id: editingProduct.id,
|
||||
company_id: auth.company.id,
|
||||
old_price: oldPrice,
|
||||
new_price: newPrice,
|
||||
change_amount: newPrice - oldPrice,
|
||||
operator_id: auth.user?.id,
|
||||
operator_name: auth.user?.username || '',
|
||||
change_reason: '产品信息编辑'
|
||||
await http.put(`/purchaser/products/${editingProduct.id}`, {
|
||||
...productData,
|
||||
yarn_ratios: yarnRatios
|
||||
});
|
||||
} else {
|
||||
await http.post('/purchaser/products', {
|
||||
...productData,
|
||||
yarn_ratios: yarnRatios
|
||||
});
|
||||
}
|
||||
|
||||
productId = editingProduct.id;
|
||||
await supabase.from('product_yarn_ratios').delete().eq('product_id', productId);
|
||||
} else {
|
||||
// 创建产品
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.insert(productData)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
handleError(error, { userMessage: '创建失败' });
|
||||
return;
|
||||
}
|
||||
productId = data.id;
|
||||
}
|
||||
|
||||
// 保存纱线配比
|
||||
const validRatios = formData.yarn_ratios.filter(r => r.yarn_name && r.ratio > 0);
|
||||
if (validRatios.length > 0) {
|
||||
await supabase.from('product_yarn_ratios').insert(
|
||||
validRatios.map(r => ({
|
||||
product_id: productId,
|
||||
yarn_name: r.yarn_name,
|
||||
ratio: r.ratio,
|
||||
yarn_type: r.yarn_type || 'all'
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// 重置并刷新
|
||||
setShowProductModal(false);
|
||||
setEditingProduct(null);
|
||||
setProductForm(initialProductForm);
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
handleError(err, { userMessage: editingProduct ? '更新失败' : '创建失败' });
|
||||
}
|
||||
}, [auth.company, auth.user, editingProduct, refetch]);
|
||||
|
||||
// 删除产品
|
||||
const handleDeleteProduct = useCallback(async (id: string) => {
|
||||
if (!confirm('确定要删除这个产品吗?')) return;
|
||||
|
||||
const { error } = await supabase.from('products').delete().eq('id', id);
|
||||
if (error) {
|
||||
handleError(error, { userMessage: '删除失败' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await purchaserApi.deleteProduct(id);
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
handleError(err, { userMessage: '删除失败' });
|
||||
}
|
||||
}, [refetch]);
|
||||
|
||||
// 编辑产品
|
||||
@ -321,15 +244,16 @@ export function WarehouseManage() {
|
||||
|
||||
// 查看价格历史
|
||||
const handleViewPriceHistory = useCallback(async (product: ProductWithInventory) => {
|
||||
const { data } = await supabase
|
||||
.from('product_price_history')
|
||||
.select('*')
|
||||
.eq('product_id', product.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
try {
|
||||
const data = await purchaserApi.priceHistory(product.id);
|
||||
setSelectedPriceHistory(data || []);
|
||||
setSelectedProductName(`${product.product_name}-${product.weight}g-${product.color}`);
|
||||
setPriceHistoryModalOpen(true);
|
||||
} catch {
|
||||
setSelectedPriceHistory([]);
|
||||
setSelectedProductName(`${product.product_name}-${product.weight}g-${product.color}`);
|
||||
setPriceHistoryModalOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 查看出入库历史 - 合并入库和出库记录
|
||||
@ -351,41 +275,26 @@ export function WarehouseManage() {
|
||||
const handleInSubmit = useCallback(async () => {
|
||||
if (!auth.company || !inForm.product_id || !inForm.batch_no) return;
|
||||
|
||||
const { error } = await supabase.from('product_inventory_records').insert({
|
||||
company_id: auth.company.id,
|
||||
product_id: inForm.product_id,
|
||||
try {
|
||||
await purchaserApi.inbound(inForm.product_id, {
|
||||
batch_no: inForm.batch_no,
|
||||
rolls: inForm.rolls,
|
||||
meters: inForm.meters,
|
||||
notes: inForm.notes || null,
|
||||
operator_id: auth.user?.id || null
|
||||
notes: inForm.notes || undefined
|
||||
});
|
||||
|
||||
if (error) {
|
||||
handleError(error, { userMessage: '入库失败' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新产品库存
|
||||
const product = products.find(p => p.id === inForm.product_id);
|
||||
if (product) {
|
||||
await supabase.from('products').update({
|
||||
raw_fabric_rolls: (product.raw_fabric_rolls || 0) + inForm.rolls,
|
||||
raw_fabric_meters: (product.raw_fabric_meters || 0) + inForm.meters,
|
||||
total_stock: (product.total_stock || 0) + inForm.meters
|
||||
}).eq('id', inForm.product_id);
|
||||
}
|
||||
|
||||
setShowInModal(false);
|
||||
setInForm(initialInboundForm);
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
handleError(err, { userMessage: '入库失败' });
|
||||
}
|
||||
}, [auth.company, auth.user, inForm, products, refetch]);
|
||||
|
||||
// 提交出库
|
||||
const handleOutSubmit = useCallback(async () => {
|
||||
if (!auth.company || !outForm.product_id || !outForm.batch_no || !outForm.source_batch_id || !outForm.recipient_company_id) return;
|
||||
|
||||
// 检查库存是否充足
|
||||
const product = products.find(p => p.id === outForm.product_id);
|
||||
if (!product) {
|
||||
handleError(new Error('产品不存在'), { userMessage: '产品不存在' });
|
||||
@ -400,35 +309,22 @@ export function WarehouseManage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('product_outbound_records').insert({
|
||||
company_id: auth.company.id,
|
||||
product_id: outForm.product_id,
|
||||
try {
|
||||
await purchaserApi.outbound(outForm.product_id, {
|
||||
batch_no: outForm.batch_no,
|
||||
rolls: outForm.rolls,
|
||||
meters: outForm.meters,
|
||||
notes: outForm.notes || null,
|
||||
operator_id: auth.user?.id || null,
|
||||
outbound_type: 'manual',
|
||||
source_batch_id: outForm.source_batch_id,
|
||||
recipient_company_id: outForm.recipient_company_id,
|
||||
recipient_company_name: outForm.recipient_company_name
|
||||
notes: outForm.notes || undefined,
|
||||
recipient_company_id: outForm.recipient_company_id
|
||||
});
|
||||
|
||||
if (error) {
|
||||
handleError(error, { userMessage: '出库失败' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新产品库存
|
||||
await supabase.from('products').update({
|
||||
raw_fabric_rolls: Math.max(0, (product.raw_fabric_rolls || 0) - outForm.rolls),
|
||||
raw_fabric_meters: Math.max(0, (product.raw_fabric_meters || 0) - outForm.meters),
|
||||
total_stock: Math.max(0, (product.total_stock || 0) - outForm.meters)
|
||||
}).eq('id', outForm.product_id);
|
||||
|
||||
setShowOutModal(false);
|
||||
setOutForm(initialOutboundForm);
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
handleError(err, { userMessage: '出库失败' });
|
||||
}
|
||||
}, [auth.company, auth.user, outForm, products, refetch]);
|
||||
|
||||
// 打开新建产品模态框
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { textileApi } from '../../api';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Link2, Warehouse, CreditCard, ArrowLeft, Factory, LogOut, FileText, TrendingUp, Package, ChevronRight, Hash, Target, Activity, Clock } from 'lucide-react';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
@ -72,41 +72,20 @@ export function TextileDashboard() {
|
||||
}, [auth.company]);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
const { data: planFactories } = await supabase
|
||||
.from('plan_factories')
|
||||
.select('plan_id')
|
||||
.eq('factory_id', auth.company!.id)
|
||||
.eq('factory_type', 'textile');
|
||||
const response = await textileApi.listPlans();
|
||||
const planData = response.data || [];
|
||||
|
||||
if (!planFactories || planFactories.length === 0) {
|
||||
setPlans([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const planIds = planFactories.map(pf => pf.plan_id);
|
||||
const { data: planData } = await supabase
|
||||
.from('production_plans')
|
||||
.select('*')
|
||||
.in('id', planIds)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
// 获取流程步骤,用于检测 rejected 状态
|
||||
const { data: stepsData } = await supabase
|
||||
.from('plan_process_steps')
|
||||
.select('plan_id, status')
|
||||
.in('plan_id', planIds);
|
||||
|
||||
// 标记有 rejected 步骤的计划
|
||||
const rejectedIds = new Set<string>();
|
||||
(stepsData || []).forEach(step => {
|
||||
planData.forEach(plan => {
|
||||
(plan.process_steps || []).forEach(step => {
|
||||
if (step.status === STEP_STATUS.REJECTED) {
|
||||
rejectedIds.add(step.plan_id);
|
||||
rejectedIds.add(plan.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
setRejectedPlanIds(rejectedIds);
|
||||
|
||||
setPlans(planData || []);
|
||||
setPlans(planData);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, Package, ChevronDown, ChevronUp, List } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { textileApi, http } from '../../api';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { InventoryRecordsModal } from '../../components/InventoryRecordsModal';
|
||||
|
||||
@ -93,103 +93,48 @@ export function FabricWarehouse() {
|
||||
useEffect(() => {
|
||||
if (!auth.company) return;
|
||||
fetchFabricInventory();
|
||||
|
||||
// 订阅入库记录变化,实现实时更新
|
||||
const subscription = supabase
|
||||
.channel('inventory_records_changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'inventory_records'
|
||||
},
|
||||
() => {
|
||||
// 有入库记录变化时刷新数据
|
||||
fetchFabricInventory();
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [auth.company]);
|
||||
|
||||
const fetchFabricInventory = async () => {
|
||||
setLoading(true);
|
||||
|
||||
// 获取纺织厂关联的所有计划
|
||||
const { data: planFactories } = await supabase
|
||||
.from('plan_factories')
|
||||
.select('plan_id')
|
||||
.eq('factory_id', auth.company!.id)
|
||||
.eq('factory_type', 'textile');
|
||||
try {
|
||||
const response = await textileApi.listPlans();
|
||||
const plansData = response.data || [];
|
||||
|
||||
if (!planFactories || planFactories.length === 0) {
|
||||
setInventory([]);
|
||||
if (plansData.length === 0) {
|
||||
setInventory(demoInventory);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const planIds = planFactories.map(pf => pf.plan_id);
|
||||
|
||||
// 获取计划对应的产品信息
|
||||
const { data: plansData } = await supabase
|
||||
.from('production_plans')
|
||||
.select('id, plan_code, fabric_code, target_quantity, completed_quantity, purchaser_id')
|
||||
.in('id', planIds);
|
||||
|
||||
if (!plansData || plansData.length === 0) {
|
||||
setInventory([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取采购商的产品信息(克重)
|
||||
const planIds = plansData.map(p => p.id);
|
||||
const purchaserIds = [...new Set(plansData.map(p => p.purchaser_id))];
|
||||
const fabricCodes = plansData.map(p => p.fabric_code);
|
||||
|
||||
const { data: productsData } = await supabase
|
||||
.from('products')
|
||||
.select('fabric_code, weight, company_id')
|
||||
.in('company_id', purchaserIds)
|
||||
.in('fabric_code', fabricCodes);
|
||||
const [productsRes, ...inventoryResults] = await Promise.all([
|
||||
http.get<any[]>('/common/products', { company_ids: purchaserIds.join(',') }),
|
||||
...planIds.map(id => textileApi.listInventory(id).catch(() => []))
|
||||
]);
|
||||
|
||||
const productWeightMap: Record<string, number> = {};
|
||||
productsData?.forEach(p => {
|
||||
(productsRes.data || []).forEach((p: any) => {
|
||||
productWeightMap[p.fabric_code] = p.weight;
|
||||
});
|
||||
|
||||
// 获取入库记录 - 从 inventory_records 表查询
|
||||
const { data: inventoryRecords } = await supabase
|
||||
.from('inventory_records')
|
||||
.select('plan_id, quantity, rolls, created_at')
|
||||
.in('plan_id', planIds)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
// 按 plan_id 分组入库记录
|
||||
const recordsByPlan: Record<string, { time: string; quantity: number; rolls: number }[]> = {};
|
||||
inventoryRecords?.forEach(record => {
|
||||
if (!recordsByPlan[record.plan_id]) {
|
||||
recordsByPlan[record.plan_id] = [];
|
||||
}
|
||||
recordsByPlan[record.plan_id].push({
|
||||
time: record.created_at,
|
||||
quantity: Number(record.quantity) || 0,
|
||||
rolls: Number(record.rolls) || 0
|
||||
});
|
||||
planIds.forEach((id, index) => {
|
||||
const records = inventoryResults[index] as any[] || [];
|
||||
recordsByPlan[id] = records.map((r: any) => ({
|
||||
time: r.created_at,
|
||||
quantity: Number(r.quantity) || 0,
|
||||
rolls: Number(r.rolls) || 0
|
||||
}));
|
||||
});
|
||||
|
||||
// 构建库存数据 - 每个计划一条记录
|
||||
// 以 inventory_records 实际入库记录为准,计算准确的已完成数量
|
||||
const inventoryList: FabricInventory[] = plansData.map(plan => {
|
||||
const records = recordsByPlan[plan.id] || [];
|
||||
// 从实际入库记录计算总米数和总匹/件数
|
||||
const totalInFromRecords = records.reduce((sum, r) => sum + r.quantity, 0);
|
||||
const totalRollsFromRecords = records.reduce((sum, r) => sum + r.rolls, 0);
|
||||
|
||||
// 使用实际入库记录的总和作为已完成数量(更准确)
|
||||
const completedQty = totalInFromRecords > 0 ? totalInFromRecords : (plan.completed_quantity || 0);
|
||||
|
||||
return {
|
||||
@ -199,15 +144,13 @@ export function FabricWarehouse() {
|
||||
plan_code: plan.plan_code,
|
||||
weight: productWeightMap[plan.fabric_code] || 0,
|
||||
target_quantity: plan.target_quantity,
|
||||
completed_quantity: completedQty, // 使用实际入库记录的总和
|
||||
completed_quantity: completedQty,
|
||||
in_records: records
|
||||
};
|
||||
});
|
||||
|
||||
setInventory(inventoryList);
|
||||
|
||||
// 如果没有真实数据,使用示例数据
|
||||
if (inventoryList.length === 0) {
|
||||
setInventory(inventoryList.length > 0 ? inventoryList : demoInventory);
|
||||
} catch {
|
||||
setInventory(demoInventory);
|
||||
}
|
||||
|
||||
|
||||
@ -3,9 +3,8 @@ import { motion } from 'framer-motion';
|
||||
import { DollarSign, CheckCircle, ArrowLeft } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { textileApi } from '../../api';
|
||||
import type { Payment, ProductionPlan } from '../../types';
|
||||
import { PAYMENT_STATUS } from '../../utils/constants';
|
||||
|
||||
export function PaymentPending() {
|
||||
const navigate = useNavigate();
|
||||
@ -21,29 +20,20 @@ export function PaymentPending() {
|
||||
}, [auth.company]);
|
||||
|
||||
const fetchPayments = async () => {
|
||||
// 获取当前纺织厂作为收款方的待结款
|
||||
const { data: paymentData } = await supabase
|
||||
.from('payments')
|
||||
.select('*')
|
||||
.eq('to_company_id', auth.company!.id)
|
||||
.eq('status', PAYMENT_STATUS.PENDING);
|
||||
const response = await textileApi.listPayments();
|
||||
const paymentData = response.data || [];
|
||||
|
||||
if (!paymentData || paymentData.length === 0) {
|
||||
if (paymentData.length === 0) {
|
||||
setPayments([]);
|
||||
setPlanMap({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取关联的计划信息
|
||||
const planIds = paymentData.map(p => p.plan_id);
|
||||
const { data: planData } = await supabase
|
||||
.from('production_plans')
|
||||
.select('*')
|
||||
.in('id', planIds);
|
||||
|
||||
const map: Record<string, ProductionPlan> = {};
|
||||
(planData || []).forEach(p => { map[p.id] = p; });
|
||||
paymentData.forEach((p: any) => {
|
||||
if (p.plan) map[p.plan_id] = p.plan;
|
||||
});
|
||||
|
||||
setPayments(paymentData);
|
||||
setPlanMap(map);
|
||||
@ -54,19 +44,10 @@ export function PaymentPending() {
|
||||
|
||||
const handleConfirmPay = async (paymentId: string) => {
|
||||
setConfirming(paymentId);
|
||||
const now = new Date().toISOString();
|
||||
const { error } = await supabase
|
||||
.from('payments')
|
||||
.update({
|
||||
status: PAYMENT_STATUS.COMPLETED,
|
||||
paid_at: now
|
||||
})
|
||||
.eq('id', paymentId);
|
||||
|
||||
if (error) {
|
||||
console.error('结款失败:', error);
|
||||
} else {
|
||||
try {
|
||||
await textileApi.confirmPayment(paymentId);
|
||||
await fetchPayments();
|
||||
} catch {
|
||||
}
|
||||
setConfirming(null);
|
||||
};
|
||||
|
||||
@ -1,13 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { textileApi, http } from '../../api';
|
||||
import { usePlanStatusSync } from '../../hooks/usePlanStatusSync';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X } from 'lucide-react';
|
||||
import { ProcessFlow } from '../../components/ProcessFlow';
|
||||
import { notifyStepCompleted, notifyPlanUpdated } from '../../utils/notifications';
|
||||
import { getSupabaseUrl } from '../../supabase/client';
|
||||
import { simpleStatusMap as statusMap, PLAN_STATUS, STEP_STATUS } from '../../utils/constants';
|
||||
import { extractParamsFromLink } from '../../utils/helpers';
|
||||
import { STORAGE_KEYS } from '../../config/app';
|
||||
@ -59,148 +58,73 @@ export function TextilePlanOverview() {
|
||||
useEffect(() => {
|
||||
if (!auth.company) return;
|
||||
fetchPlans();
|
||||
|
||||
// 订阅入库记录变化,实现实时更新
|
||||
const subscription = supabase
|
||||
.channel('plan_overview_inventory_changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'inventory_records'
|
||||
},
|
||||
() => {
|
||||
// 有入库记录变化时刷新数据
|
||||
fetchPlans();
|
||||
}
|
||||
)
|
||||
// 订阅 plan_factories 变化,导入新计划后自动刷新
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'plan_factories'
|
||||
},
|
||||
(payload) => {
|
||||
// 仅当关联到当前纺织厂时刷新
|
||||
if (payload.new && (payload.new as any).factory_id === auth.company!.id) {
|
||||
fetchPlans();
|
||||
}
|
||||
// 删除关联时也刷新
|
||||
if (payload.eventType === 'DELETE') {
|
||||
fetchPlans();
|
||||
}
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [auth.company]);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
// 获取关联到当前纺织厂的计划
|
||||
const { data: planFactories } = await supabase
|
||||
.from('plan_factories')
|
||||
.select('plan_id')
|
||||
.eq('factory_id', auth.company!.id)
|
||||
.eq('factory_type', 'textile');
|
||||
const response = await textileApi.listPlans();
|
||||
const planData = response.data || [];
|
||||
|
||||
if (!planFactories || planFactories.length === 0) {
|
||||
if (planData.length === 0) {
|
||||
setGroups([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const planIds = planFactories.map(pf => pf.plan_id);
|
||||
const planIds = planData.map(p => p.id);
|
||||
|
||||
// 获取计划详情
|
||||
const { data: planData } = await supabase
|
||||
.from('production_plans')
|
||||
.select('*')
|
||||
.in('id', planIds)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!planData || planData.length === 0) {
|
||||
setGroups([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取所有流程步骤
|
||||
const { data: stepsData } = await supabase
|
||||
.from('plan_process_steps')
|
||||
.select('*')
|
||||
.in('plan_id', planIds);
|
||||
|
||||
// 获取操作人信息
|
||||
const operatorIds = [...new Set((stepsData || []).filter(s => s.operator_id).map(s => s.operator_id).filter(Boolean))] as string[];
|
||||
const operatorIds = [...new Set(
|
||||
planData.flatMap(p => (p.process_steps || []).filter(s => s.operator_id).map(s => s.operator_id!))
|
||||
)];
|
||||
if (operatorIds.length > 0) {
|
||||
const { data: profilesData } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, username')
|
||||
.in('id', operatorIds);
|
||||
|
||||
try {
|
||||
const { data: profilesData } = await http.get<any[]>('/common/profiles', { ids: operatorIds.join(',') });
|
||||
const names: Record<string, string> = {};
|
||||
(profilesData || []).forEach(p => {
|
||||
names[p.id] = p.username;
|
||||
});
|
||||
(profilesData || []).forEach(p => { names[p.id] = p.username; });
|
||||
setOperatorNames(names);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 获取采购商公司信息
|
||||
const purchaserIds = [...new Set(planData.map(p => p.purchaser_id))];
|
||||
const { data: companiesData } = await supabase
|
||||
.from('companies')
|
||||
.select('*')
|
||||
.in('id', purchaserIds);
|
||||
|
||||
const companyMap: Record<string, string> = {};
|
||||
(companiesData || []).forEach(c => { companyMap[c.id] = c.name; });
|
||||
planData.forEach(p => {
|
||||
if (p.purchaser_name) companyMap[p.purchaser_id] = p.purchaser_name;
|
||||
});
|
||||
|
||||
// 获取所有计划的入库记录
|
||||
const { data: inventoryData } = await supabase
|
||||
.from('inventory_records')
|
||||
.select('plan_id, quantity, rolls, created_at')
|
||||
.in('plan_id', planIds)
|
||||
.order('created_at', { ascending: false });
|
||||
const inventoryResults = await Promise.all(
|
||||
planIds.map(id => textileApi.listInventory(id).catch(() => [] as any[]))
|
||||
);
|
||||
const inventoryByPlan: Record<string, any[]> = {};
|
||||
planIds.forEach((id, idx) => { inventoryByPlan[id] = inventoryResults[idx] || []; });
|
||||
|
||||
// 获取纱线配比
|
||||
const { data: yarnRatiosData } = await supabase
|
||||
.from('yarn_ratios')
|
||||
.select('*')
|
||||
.in('plan_id', planIds);
|
||||
let yarnRatiosData: any[] = [];
|
||||
let productsData: any[] = [];
|
||||
try {
|
||||
const [yrRes, prRes] = await Promise.all([
|
||||
http.get<any[]>('/textile/yarn-ratios', { plan_ids: planIds.join(',') }),
|
||||
http.get<any[]>('/common/products', { company_ids: purchaserIds.join(',') })
|
||||
]);
|
||||
yarnRatiosData = yrRes.data || [];
|
||||
productsData = prRes.data || [];
|
||||
} catch {}
|
||||
|
||||
// 获取产品信息(克重、图片等)
|
||||
const { data: productsData } = await supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.in('company_id', purchaserIds);
|
||||
|
||||
// 按采购商分组
|
||||
const grouped: Record<string, PlanGroup> = {};
|
||||
planData.forEach(plan => {
|
||||
const pid = plan.purchaser_id;
|
||||
if (!grouped[pid]) {
|
||||
grouped[pid] = {
|
||||
purchaserId: pid,
|
||||
purchaserName: companyMap[pid] || '未知布行',
|
||||
purchaserName: companyMap[pid] || plan.purchaser_name || '未知布行',
|
||||
plans: []
|
||||
};
|
||||
}
|
||||
const processSteps = (stepsData || [])
|
||||
.filter(s => s.plan_id === plan.id)
|
||||
const processSteps = (plan.process_steps || [])
|
||||
.sort((a, b) => {
|
||||
const order = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse'];
|
||||
return order.indexOf(a.step_type) - order.indexOf(b.step_type);
|
||||
});
|
||||
const inventoryRecords = (inventoryData || [])
|
||||
.filter(r => r.plan_id === plan.id)
|
||||
.map(r => ({ time: r.created_at, quantity: r.quantity, rolls: r.rolls }));
|
||||
const yarnRatios = (yarnRatiosData || [])
|
||||
const inventoryRecords = (inventoryByPlan[plan.id] || [])
|
||||
.map((r: any) => ({ time: r.created_at, quantity: r.quantity, rolls: r.rolls }));
|
||||
const yarnRatios = (yarnRatiosData)
|
||||
.filter(y => y.plan_id === plan.id)
|
||||
.map(y => ({
|
||||
id: y.id,
|
||||
@ -213,8 +137,7 @@ export function TextilePlanOverview() {
|
||||
created_at: y.created_at,
|
||||
company_id: y.company_id
|
||||
}));
|
||||
// 查找关联的产品信息
|
||||
const product = (productsData || []).find(p =>
|
||||
const product = productsData.find(p =>
|
||||
p.fabric_code === plan.fabric_code && p.company_id === plan.purchaser_id
|
||||
);
|
||||
grouped[pid].plans.push({
|
||||
@ -232,7 +155,6 @@ export function TextilePlanOverview() {
|
||||
|
||||
const groupList = Object.values(grouped);
|
||||
setGroups(groupList);
|
||||
// 默认展开第一个组
|
||||
if (groupList.length > 0) {
|
||||
setExpandedGroups(new Set([groupList[0].purchaserId]));
|
||||
}
|
||||
@ -310,54 +232,31 @@ export function TextilePlanOverview() {
|
||||
}
|
||||
|
||||
setRejecting(rejectStepId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
// 获取步骤信息
|
||||
const { data: step } = await supabase
|
||||
.from('plan_process_steps')
|
||||
.select('plan_id')
|
||||
.eq('id', rejectStepId)
|
||||
.maybeSingle();
|
||||
const planId = groups.flatMap(g => g.plans).find(
|
||||
p => p.processSteps.some(s => s.id === rejectStepId)
|
||||
)?.id;
|
||||
|
||||
if (!step?.plan_id) {
|
||||
if (!planId) {
|
||||
setRejecting(null);
|
||||
handleCloseRejectModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新步骤状态为拒绝(使用 notes 字段记录拒绝原因)
|
||||
const { error } = await supabase
|
||||
.from('plan_process_steps')
|
||||
.update({
|
||||
status: STEP_STATUS.REJECTED,
|
||||
notes: `拒绝原因: ${rejectReason.trim()}`,
|
||||
timestamp: now,
|
||||
operator_id: auth.user?.id
|
||||
})
|
||||
.eq('id', rejectStepId);
|
||||
await textileApi.rejectStep(planId, rejectStepId, { reason: rejectReason.trim() });
|
||||
|
||||
if (error) {
|
||||
handleError(error, { userMessage: '拒绝失败,请重试' });
|
||||
setRejecting(null);
|
||||
return;
|
||||
}
|
||||
await notifyPlanUpdated(planId, PLAN_STATUS.REJECTED, auth.company!.id, auth.user!.id);
|
||||
|
||||
// 发送拒绝通知给采购商
|
||||
await notifyPlanUpdated(step.plan_id, PLAN_STATUS.REJECTED, auth.company!.id, auth.user!.id);
|
||||
setRejectedPlans(prev => new Set([...prev, planId]));
|
||||
|
||||
// 将该计划标记为已拒绝(本地状态,显示灰色)
|
||||
setRejectedPlans(prev => new Set([...prev, step.plan_id]));
|
||||
|
||||
// 存储到 localStorage,让 Dashboard 也能过滤掉
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.textileRejectedPlans);
|
||||
const rejectedArray = stored ? JSON.parse(stored) : [];
|
||||
if (!rejectedArray.includes(step.plan_id)) {
|
||||
rejectedArray.push(step.plan_id);
|
||||
if (!rejectedArray.includes(planId)) {
|
||||
rejectedArray.push(planId);
|
||||
localStorage.setItem(STORAGE_KEYS.textileRejectedPlans, JSON.stringify(rejectedArray));
|
||||
}
|
||||
|
||||
// 关闭弹窗并收起计划卡片
|
||||
handleCloseRejectModal();
|
||||
setExpandedPlan(null);
|
||||
} catch (err) {
|
||||
@ -368,230 +267,34 @@ export function TextilePlanOverview() {
|
||||
|
||||
const handleConfirmStep = async (stepId: string, quantity?: number, rolls?: number) => {
|
||||
setConfirming(stepId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// 获取步骤信息
|
||||
const { data: step } = await supabase
|
||||
.from('plan_process_steps')
|
||||
.select('step_type, plan_id')
|
||||
.eq('id', stepId)
|
||||
.maybeSingle();
|
||||
const plan = groups.flatMap(g => g.plans).find(
|
||||
p => p.processSteps.some(s => s.id === stepId)
|
||||
);
|
||||
const step = plan?.processSteps.find(s => s.id === stepId);
|
||||
const planId = plan?.id;
|
||||
|
||||
const planId = step?.plan_id;
|
||||
|
||||
if (!planId) {
|
||||
if (!planId || !step) {
|
||||
setConfirming(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是坯布入库,使用 Edge Function 进行事务处理
|
||||
if (step?.step_type === 'fabric_warehouse' && quantity && quantity > 0) {
|
||||
// 获取纺织厂的仓库和工厂信息
|
||||
const { data: warehouse } = await supabase
|
||||
.from('warehouses')
|
||||
.select('id')
|
||||
.eq('company_id', auth.company!.id)
|
||||
.eq('type', 'fabric')
|
||||
.maybeSingle();
|
||||
|
||||
// 获取工厂信息(地址或名称)
|
||||
const { data: factoryInfo } = await supabase
|
||||
.from('companies')
|
||||
.select('name, address')
|
||||
.eq('id', auth.company!.id)
|
||||
.maybeSingle();
|
||||
|
||||
// 仓库位置:优先使用地址,没有则使用工厂名称
|
||||
const warehouseLocation = factoryInfo?.address || factoryInfo?.name || '';
|
||||
|
||||
// 获取计划信息(包括采购商ID和采购价)
|
||||
const { data: plan } = await supabase
|
||||
.from('production_plans')
|
||||
.select('completed_quantity, target_quantity, purchaser_id, production_price')
|
||||
.eq('id', planId)
|
||||
.maybeSingle();
|
||||
|
||||
if (plan) {
|
||||
const newCompletedQuantity = plan.completed_quantity + quantity;
|
||||
const progressPercent = (newCompletedQuantity / plan.target_quantity) * 100;
|
||||
const pricePerMeter = plan.production_price || 0;
|
||||
const amount = quantity * pricePerMeter;
|
||||
|
||||
// 使用 Edge Function 进行入库事务处理
|
||||
const session = (await supabase.auth.getSession()).data.session;
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (session) {
|
||||
headers['Authorization'] = `Bearer ${session.access_token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${getSupabaseUrl()}/functions/v1/fabric-warehouse-inbound`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
planId: planId,
|
||||
quantity: quantity,
|
||||
try {
|
||||
if (step.step_type === 'fabric_warehouse' && quantity && quantity > 0) {
|
||||
await textileApi.inbound(planId, {
|
||||
quantity,
|
||||
rolls: rolls || 0,
|
||||
warehouseId: warehouse?.id || null,
|
||||
warehouseLocation: warehouseLocation,
|
||||
operatorId: auth.user?.id,
|
||||
pricePerMeter: pricePerMeter,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || '入库操作失败');
|
||||
}
|
||||
|
||||
const inventoryRecord = result.data?.inventoryRecord;
|
||||
|
||||
// 创建或更新应付账款记录
|
||||
if (inventoryRecord) {
|
||||
// 检查是否已存在应付账款记录
|
||||
const { data: existingAP } = await supabase
|
||||
.from('accounts_payable')
|
||||
.select('*')
|
||||
.eq('plan_id', planId)
|
||||
.eq('textile_factory_id', auth.company!.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (existingAP) {
|
||||
// 更新应付账款
|
||||
await supabase
|
||||
.from('accounts_payable')
|
||||
.update({
|
||||
total_quantity: existingAP.total_quantity + quantity,
|
||||
total_amount: existingAP.total_amount + amount,
|
||||
unpaid_quantity: existingAP.unpaid_quantity + quantity,
|
||||
unpaid_amount: existingAP.unpaid_amount + amount,
|
||||
updated_at: now
|
||||
})
|
||||
.eq('id', existingAP.id);
|
||||
|
||||
// 创建应付账款明细
|
||||
await supabase
|
||||
.from('accounts_payable_items')
|
||||
.insert({
|
||||
accounts_payable_id: existingAP.id,
|
||||
inventory_record_id: inventoryRecord.id,
|
||||
quantity: quantity,
|
||||
rolls: rolls || 0,
|
||||
price_per_meter: pricePerMeter,
|
||||
amount: amount,
|
||||
status: 'unpaid',
|
||||
created_at: now
|
||||
await textileApi.completeStep(planId, stepId, {
|
||||
operator_name: auth.user?.username
|
||||
});
|
||||
} else {
|
||||
// 创建新的应付账款
|
||||
const { data: newAP } = await supabase
|
||||
.from('accounts_payable')
|
||||
.insert({
|
||||
plan_id: planId,
|
||||
purchaser_id: plan.purchaser_id,
|
||||
textile_factory_id: auth.company!.id,
|
||||
total_quantity: quantity,
|
||||
total_amount: amount,
|
||||
unpaid_quantity: quantity,
|
||||
unpaid_amount: amount,
|
||||
price_per_meter: pricePerMeter,
|
||||
status: 'unpaid',
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (newAP) {
|
||||
// 创建应付账款明细
|
||||
await supabase
|
||||
.from('accounts_payable_items')
|
||||
.insert({
|
||||
accounts_payable_id: newAP.id,
|
||||
inventory_record_id: inventoryRecord.id,
|
||||
quantity: quantity,
|
||||
rolls: rolls || 0,
|
||||
price_per_meter: pricePerMeter,
|
||||
amount: amount,
|
||||
status: 'unpaid',
|
||||
created_at: now
|
||||
await textileApi.completeStep(planId, stepId, {
|
||||
operator_name: auth.user?.username
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果进度达到97%或以上,标记步骤为完成,否则标记为进行中
|
||||
if (progressPercent >= 97) {
|
||||
await supabase
|
||||
.from('plan_process_steps')
|
||||
.update({
|
||||
status: STEP_STATUS.COMPLETED,
|
||||
timestamp: now,
|
||||
operator_id: auth.user?.id
|
||||
})
|
||||
.eq('id', stepId);
|
||||
} else {
|
||||
// 进度未达到97%,标记步骤为进行中(active)
|
||||
await supabase
|
||||
.from('plan_process_steps')
|
||||
.update({
|
||||
status: STEP_STATUS.ACTIVE,
|
||||
timestamp: now,
|
||||
operator_id: auth.user?.id
|
||||
})
|
||||
.eq('id', stepId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 其他步骤直接更新状态
|
||||
const { error } = await supabase
|
||||
.from('plan_process_steps')
|
||||
.update({
|
||||
status: STEP_STATUS.COMPLETED,
|
||||
timestamp: now,
|
||||
operator_id: auth.user?.id
|
||||
})
|
||||
.eq('id', stepId);
|
||||
|
||||
if (error) {
|
||||
handleError(error, { userMessage: '确认失败,请重试' });
|
||||
setConfirming(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新计划状态 - 无论步骤类型,只要确认成功就更新计划状态
|
||||
if (step?.step_type === 'confirm') {
|
||||
const { error: planUpdateError } = await supabase
|
||||
.from('production_plans')
|
||||
.update({ status: PLAN_STATUS.PRODUCING })
|
||||
.eq('id', planId);
|
||||
|
||||
if (planUpdateError) {
|
||||
handleError(planUpdateError, { userMessage: '步骤已确认,但更新计划状态失败,请刷新页面' });
|
||||
} else {
|
||||
// 发送计划开始生产通知
|
||||
await notifyPlanUpdated(planId, PLAN_STATUS.PRODUCING, auth.company!.id, auth.user!.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否所有步骤都已完成
|
||||
const { data: allSteps } = await supabase
|
||||
.from('plan_process_steps')
|
||||
.select('status')
|
||||
.eq('plan_id', planId);
|
||||
|
||||
const allCompleted = allSteps?.every(s => s.status === STEP_STATUS.COMPLETED);
|
||||
if (allCompleted) {
|
||||
await supabase
|
||||
.from('production_plans')
|
||||
.update({ status: PLAN_STATUS.COMPLETED })
|
||||
.eq('id', planId);
|
||||
|
||||
// 发送计划完成通知
|
||||
await notifyPlanUpdated(planId, PLAN_STATUS.COMPLETED, auth.company!.id, auth.user!.id);
|
||||
}
|
||||
|
||||
// 发送节点完成通知
|
||||
const stepNames: Record<string, string> = {
|
||||
confirm: '确认计划',
|
||||
yarn_purchase: '采纱',
|
||||
@ -599,17 +302,19 @@ export function TextilePlanOverview() {
|
||||
machine_start: '上机',
|
||||
fabric_warehouse: '坯布生产'
|
||||
};
|
||||
const stepName = stepNames[step?.step_type || ''] || '生产节点';
|
||||
const stepName = stepNames[step.step_type || ''] || '生产节点';
|
||||
await notifyStepCompleted(
|
||||
planId,
|
||||
stepName,
|
||||
auth.company!.id,
|
||||
auth.user!.id,
|
||||
step?.step_type === 'fabric_warehouse' ? { quantity, rolls } : undefined
|
||||
step.step_type === 'fabric_warehouse' ? { quantity, rolls } : undefined
|
||||
);
|
||||
|
||||
// 刷新数据
|
||||
await fetchPlans();
|
||||
} catch (err) {
|
||||
handleError(err, { userMessage: '确认失败,请重试' });
|
||||
}
|
||||
setConfirming(null);
|
||||
};
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, Plus, AlertTriangle, ArrowDownLeft, ArrowUpRight } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { textileApi, http } from '../../api';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { YarnStock, Warehouse } from '../../types';
|
||||
|
||||
@ -42,45 +42,25 @@ export function YarnWarehouse() {
|
||||
}, [activeTab, auth.company]);
|
||||
|
||||
const fetchData = async () => {
|
||||
const { data: yarnData } = await supabase
|
||||
.from('yarn_stock')
|
||||
.select('*')
|
||||
.eq('company_id', auth.company!.id)
|
||||
.order('name');
|
||||
const response = await textileApi.listYarnStock();
|
||||
setYarns(response.data || []);
|
||||
|
||||
const { data: whData } = await supabase
|
||||
.from('warehouses')
|
||||
.select('*')
|
||||
.eq('company_id', auth.company!.id)
|
||||
.eq('type', 'yarn');
|
||||
|
||||
setYarns(yarnData || []);
|
||||
const { data: whData } = await http.get<Warehouse[]>('/textile/warehouses', { type: 'yarn' });
|
||||
setWarehouses(whData || []);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchRecords = async () => {
|
||||
setLoading(true);
|
||||
const { data: recordsData } = await supabase
|
||||
.from('yarn_stock_records')
|
||||
.select('*')
|
||||
.eq('company_id', auth.company!.id)
|
||||
.order('created_at', { ascending: false });
|
||||
const { data: recordsData } = await http.get<any[]>('/textile/yarn-stock/records');
|
||||
|
||||
// 获取纱线名称
|
||||
const yarnIds = recordsData?.map(r => r.yarn_stock_id).filter((id): id is string => !!id) || [];
|
||||
const { data: yarnsData } = await supabase
|
||||
.from('yarn_stock')
|
||||
.select('id, name')
|
||||
.in('id', yarnIds.length > 0 ? yarnIds : ['00000000-0000-0000-0000-000000000000']);
|
||||
const yarnMap = new Map(yarns.map(y => [y.id, y.name]));
|
||||
|
||||
const yarnMap = new Map(yarnsData?.map(y => [y.id, y.name]) || []);
|
||||
|
||||
const recordsWithNames: YarnStockRecord[] = recordsData?.map(r => ({
|
||||
const recordsWithNames: YarnStockRecord[] = (recordsData || []).map(r => ({
|
||||
...r,
|
||||
yarn_name: r.yarn_stock_id ? yarnMap.get(r.yarn_stock_id) || '未知纱线' : '未知纱线',
|
||||
yarn_name: r.yarn_stock_id ? yarnMap.get(r.yarn_stock_id) || r.yarn_name || '未知纱线' : '未知纱线',
|
||||
record_type: r.record_type as 'inbound' | 'outbound'
|
||||
})) || [];
|
||||
}));
|
||||
|
||||
setRecords(recordsWithNames);
|
||||
setLoading(false);
|
||||
@ -88,16 +68,10 @@ export function YarnWarehouse() {
|
||||
|
||||
const handleAddYarn = async () => {
|
||||
if (!newYarn.name) return;
|
||||
const yarnWarehouse = warehouses[0]; // 使用第一个纱线仓库
|
||||
const yarnWarehouse = warehouses[0];
|
||||
|
||||
// 获取当前用户ID
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
const operatorId = user?.id;
|
||||
|
||||
// 插入纱线库存
|
||||
const { data: newYarnData, error } = await supabase
|
||||
.from('yarn_stock')
|
||||
.insert({
|
||||
try {
|
||||
const newYarnData = await textileApi.createYarnStock({
|
||||
company_id: auth.company!.id,
|
||||
warehouse_id: yarnWarehouse?.id || null,
|
||||
name: newYarn.name,
|
||||
@ -105,27 +79,18 @@ export function YarnWarehouse() {
|
||||
quantity: newYarn.quantity,
|
||||
min_stock: newYarn.minStock,
|
||||
unit: newYarn.unit
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
} as any);
|
||||
|
||||
if (error) {
|
||||
console.error('添加失败:', error);
|
||||
} else {
|
||||
// 记录入库
|
||||
await supabase.from('yarn_stock_records').insert({
|
||||
company_id: auth.company!.id,
|
||||
yarn_stock_id: newYarnData.id,
|
||||
record_type: 'inbound',
|
||||
await textileApi.addYarnRecord(newYarnData.id, {
|
||||
change_type: 'inbound',
|
||||
quantity: newYarn.quantity,
|
||||
unit: newYarn.unit,
|
||||
notes: `纱线入库 - ${newYarn.name}`,
|
||||
operator_id: operatorId
|
||||
notes: `纱线入库 - ${newYarn.name}`
|
||||
});
|
||||
|
||||
setShowAddModal(false);
|
||||
setNewYarn({ name: '', spec: '', quantity: 0, minStock: 0, unit: 'kg' });
|
||||
await fetchData();
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { washingApi } from '../../api';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, CheckCircle, Droplets, ChevronRight } from 'lucide-react';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
import type { WashingPlan, WashingPlanCompletion } from '../../types';
|
||||
import { WASHING_PLAN_STATUS } from '../../utils/constants';
|
||||
import type { WashingPlan } from '../../types';
|
||||
|
||||
interface CompletedFabricItem {
|
||||
id: string;
|
||||
@ -38,51 +37,25 @@ export function CompletedFabric() {
|
||||
const fetchCompletedFabric = async () => {
|
||||
setLoading(true);
|
||||
|
||||
// 获取已完成的水洗计划
|
||||
const { data: washingPlans, error } = await supabase
|
||||
.from('washing_plans')
|
||||
.select(`
|
||||
*,
|
||||
product:product_id(product_name, color, fabric_code),
|
||||
company:company_id(name)
|
||||
`)
|
||||
.eq('washing_factory_id', auth.company!.id)
|
||||
.eq('status', WASHING_PLAN_STATUS.COMPLETED)
|
||||
.order('updated_at', { ascending: false });
|
||||
try {
|
||||
const data = await washingApi.listCompletedPlans();
|
||||
|
||||
if (error) {
|
||||
console.error('获取已完成坯布失败:', error);
|
||||
} else {
|
||||
// 获取完成记录详情
|
||||
const planIds = (washingPlans || []).map(p => p.id);
|
||||
const { data: completions } = await supabase
|
||||
.from('washing_plan_completions')
|
||||
.select('*')
|
||||
.in('washing_plan_id', planIds);
|
||||
|
||||
const completionMap: Record<string, WashingPlanCompletion> = {};
|
||||
(completions || []).forEach(c => {
|
||||
completionMap[c.washing_plan_id] = c;
|
||||
});
|
||||
|
||||
const formattedItems = (washingPlans || []).map(plan => {
|
||||
const completion = completionMap[plan.id];
|
||||
return {
|
||||
const formattedItems = (data || []).map((plan: any) => ({
|
||||
id: plan.id,
|
||||
plan_code: plan.plan_code,
|
||||
purchaser_name: (plan.company as any)?.name || '未知采购商',
|
||||
product_name: (plan.product as any)?.product_name || '-',
|
||||
product_color: (plan.product as any)?.color || '-',
|
||||
fabric_code: (plan.product as any)?.fabric_code || '-',
|
||||
purchaser_name: plan.purchaser_name || plan.company?.name || '未知采购商',
|
||||
product_name: plan.product_name || plan.product?.product_name || '-',
|
||||
product_color: plan.product_color || plan.product?.color || '-',
|
||||
fabric_code: plan.fabric_code || plan.product?.fabric_code || '-',
|
||||
planned_meters: plan.planned_meters,
|
||||
actual_washed_meters: completion?.actual_washed_meters || plan.actual_washed_meters || 0,
|
||||
shrinkage_rate: completion?.actual_shrinkage_rate || plan.actual_shrinkage_rate || 0,
|
||||
washing_date: completion?.washing_date || plan.washing_date || '',
|
||||
rolls: completion?.rolls || 0,
|
||||
completed_at: completion?.completed_at || plan.updated_at
|
||||
};
|
||||
});
|
||||
actual_washed_meters: plan.completion?.actual_washed_meters || plan.actual_washed_meters || 0,
|
||||
shrinkage_rate: plan.completion?.actual_shrinkage_rate || plan.actual_shrinkage_rate || 0,
|
||||
washing_date: plan.completion?.washing_date || plan.washing_date || '',
|
||||
rolls: plan.completion?.rolls || 0,
|
||||
completed_at: plan.completion?.completed_at || plan.updated_at
|
||||
}));
|
||||
setItems(formattedItems);
|
||||
} catch {
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { washingApi } from '../../api';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Import, Warehouse, CreditCard, ArrowLeft, Users, Droplets, Package, CheckCircle, Clock, LogOut, FileText, Plus, TrendingUp, Building2, ChevronRight, Hash, Target, Activity } from 'lucide-react';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
@ -74,13 +74,8 @@ export function WashingDashboard() {
|
||||
}, [auth.company]);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
const { data: washingPlans } = await supabase
|
||||
.from('washing_plans')
|
||||
.select('*')
|
||||
.eq('washing_factory_id', auth.company!.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
setPlans(washingPlans || []);
|
||||
const response = await washingApi.listPlans();
|
||||
setPlans(response.data || []);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { washingApi, http } from '../../api';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ArrowLeft, Package, Search, Filter, History, ArrowUpRight, ArrowDownRight, Boxes } from 'lucide-react';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
@ -39,45 +39,33 @@ export function FinishedWarehouse() {
|
||||
|
||||
const fetchProducts = async () => {
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase
|
||||
.from('finished_products')
|
||||
.select(`
|
||||
*,
|
||||
warehouse:warehouse_id(name)
|
||||
`)
|
||||
.eq('company_id', auth.company!.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('获取成品失败:', error);
|
||||
} else {
|
||||
const productsWithDetails = (data || []).map(product => ({
|
||||
try {
|
||||
const response = await washingApi.listFinishedProducts();
|
||||
const productsWithDetails = (response.data || []).map((product: any) => ({
|
||||
...product,
|
||||
warehouse_name: (product.warehouse as any)?.name
|
||||
warehouse_name: product.warehouse?.name || product.warehouse_name
|
||||
}));
|
||||
setProducts(productsWithDetails);
|
||||
} catch {
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchWarehouses = async () => {
|
||||
const { data } = await supabase
|
||||
.from('warehouses')
|
||||
.select('*')
|
||||
.eq('company_id', auth.company!.id)
|
||||
.eq('type', 'finished');
|
||||
try {
|
||||
const { data } = await http.get<Warehouse[]>('/washing/warehouses', { type: 'finished' });
|
||||
setWarehouses(data || []);
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchInventoryRecords = async (productId: string) => {
|
||||
const { data, error } = await supabase
|
||||
.from('finished_product_inventory_records')
|
||||
.select('*')
|
||||
.eq('finished_product_id', productId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (!error) {
|
||||
try {
|
||||
const { data } = await http.get<FinishedProductInventoryRecord[]>(
|
||||
`/washing/finished-products/${productId}/records`
|
||||
);
|
||||
setInventoryRecords(data || []);
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -3,9 +3,8 @@ import { motion } from 'framer-motion';
|
||||
import { DollarSign, CheckCircle, ArrowLeft } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { washingApi } from '../../api';
|
||||
import type { Payment, WashingPlan } from '../../types';
|
||||
import { PAYMENT_STATUS } from '../../utils/constants';
|
||||
|
||||
interface PaymentWithPlan extends Payment {
|
||||
plan?: WashingPlan;
|
||||
@ -24,32 +23,18 @@ export function WashingPaymentPending() {
|
||||
}, [auth.company]);
|
||||
|
||||
const fetchPayments = async () => {
|
||||
// 获取当前水洗厂作为收款方的待结款
|
||||
const { data: paymentData } = await supabase
|
||||
.from('payments')
|
||||
.select('*')
|
||||
.eq('to_company_id', auth.company!.id)
|
||||
.eq('status', PAYMENT_STATUS.PENDING);
|
||||
const response = await washingApi.listPayments();
|
||||
const paymentData = response.data || [];
|
||||
|
||||
if (!paymentData || paymentData.length === 0) {
|
||||
if (paymentData.length === 0) {
|
||||
setPayments([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取关联的水洗计划信息
|
||||
const planIds = paymentData.map(p => p.plan_id);
|
||||
const { data: planData } = await supabase
|
||||
.from('washing_plans')
|
||||
.select('*')
|
||||
.in('id', planIds);
|
||||
|
||||
const planMap: Record<string, WashingPlan> = {};
|
||||
(planData || []).forEach(p => { planMap[p.id] = p; });
|
||||
|
||||
const paymentsWithPlans = paymentData.map(p => ({
|
||||
const paymentsWithPlans = paymentData.map((p: any) => ({
|
||||
...p,
|
||||
plan: planMap[p.plan_id]
|
||||
plan: p.washing_plan
|
||||
}));
|
||||
|
||||
setPayments(paymentsWithPlans);
|
||||
@ -60,19 +45,10 @@ export function WashingPaymentPending() {
|
||||
|
||||
const handleConfirmPay = async (paymentId: string) => {
|
||||
setConfirming(paymentId);
|
||||
const now = new Date().toISOString();
|
||||
const { error } = await supabase
|
||||
.from('payments')
|
||||
.update({
|
||||
status: PAYMENT_STATUS.COMPLETED,
|
||||
paid_at: now
|
||||
})
|
||||
.eq('id', paymentId);
|
||||
|
||||
if (error) {
|
||||
console.error('结款失败:', error);
|
||||
} else {
|
||||
try {
|
||||
await washingApi.confirmPayment(paymentId);
|
||||
await fetchPayments();
|
||||
} catch {
|
||||
}
|
||||
setConfirming(null);
|
||||
};
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { washingApi } from '../../api';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, Package, Clock, ChevronRight } from 'lucide-react';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
import type { WashingPlan } from '../../types';
|
||||
import { WASHING_PLAN_STATUS } from '../../utils/constants';
|
||||
|
||||
interface PendingFabricItem {
|
||||
id: string;
|
||||
@ -40,32 +39,21 @@ export function PendingFabric() {
|
||||
const fetchPendingFabric = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('washing_plans')
|
||||
.select(`
|
||||
*,
|
||||
product:product_id(product_name, color, fabric_code),
|
||||
company:company_id(name)
|
||||
`)
|
||||
.eq('washing_factory_id', auth.company!.id)
|
||||
.in('status', [WASHING_PLAN_STATUS.PENDING, 'processing'])
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('获取待处理坯布失败:', error);
|
||||
} else {
|
||||
const formattedItems = (data || []).map(plan => ({
|
||||
try {
|
||||
const data = await washingApi.listPendingPlans();
|
||||
const formattedItems = (data || []).map((plan: any) => ({
|
||||
id: plan.id,
|
||||
plan_code: plan.plan_code,
|
||||
purchaser_name: (plan.company as any)?.name || '未知采购商',
|
||||
product_name: (plan.product as any)?.product_name || '-',
|
||||
product_color: (plan.product as any)?.color || '-',
|
||||
fabric_code: (plan.product as any)?.fabric_code || '-',
|
||||
purchaser_name: plan.purchaser_name || plan.company?.name || '未知采购商',
|
||||
product_name: plan.product_name || plan.product?.product_name || '-',
|
||||
product_color: plan.product_color || plan.product?.color || '-',
|
||||
fabric_code: plan.fabric_code || plan.product?.fabric_code || '-',
|
||||
planned_meters: plan.planned_meters,
|
||||
status: plan.status,
|
||||
created_at: plan.created_at
|
||||
}));
|
||||
setItems(formattedItems);
|
||||
} catch {
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { supabase } from '../../supabase/client';
|
||||
import { washingApi, http } from '../../api';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2, X, Droplets, Package, Clock } from 'lucide-react';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
@ -64,24 +64,11 @@ export function WashingPlanOverview() {
|
||||
const fetchPlans = async () => {
|
||||
setLoading(true);
|
||||
|
||||
// 获取水洗计划
|
||||
const { data: washingPlans, error } = await supabase
|
||||
.from('washing_plans')
|
||||
.select(`
|
||||
*,
|
||||
product:product_id(product_name, color, fabric_code),
|
||||
company:company_id(name)
|
||||
`)
|
||||
.eq('washing_factory_id', auth.company!.id)
|
||||
.order('created_at', { ascending: false });
|
||||
try {
|
||||
const response = await washingApi.listPlans();
|
||||
const washingPlans = response.data || [];
|
||||
|
||||
if (error) {
|
||||
handleError(error, { userMessage: '获取水洗计划失败' });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!washingPlans || washingPlans.length === 0) {
|
||||
if (washingPlans.length === 0) {
|
||||
setGroups([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
@ -89,42 +76,32 @@ export function WashingPlanOverview() {
|
||||
|
||||
const planIds = washingPlans.map(p => p.id);
|
||||
|
||||
// 获取流程步骤
|
||||
const { data: stepsData } = await supabase
|
||||
.from('washing_process_steps')
|
||||
.select('*')
|
||||
.in('washing_plan_id', planIds);
|
||||
let stepsData: any[] = [];
|
||||
try {
|
||||
const { data } = await http.get<any[]>('/washing/process-steps', { plan_ids: planIds.join(',') });
|
||||
stepsData = data || [];
|
||||
} catch {}
|
||||
|
||||
// 获取操作人信息
|
||||
const operatorIds = [...new Set((stepsData || []).filter(s => s.operator_id).map(s => s.operator_id).filter(Boolean))] as string[];
|
||||
const operatorIds = [...new Set(stepsData.filter(s => s.operator_id).map(s => s.operator_id))] as string[];
|
||||
if (operatorIds.length > 0) {
|
||||
const { data: profilesData } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, username')
|
||||
.in('id', operatorIds);
|
||||
|
||||
try {
|
||||
const { data: profilesData } = await http.get<any[]>('/common/profiles', { ids: operatorIds.join(',') });
|
||||
const names: Record<string, string> = {};
|
||||
(profilesData || []).forEach(p => {
|
||||
names[p.id] = p.username;
|
||||
});
|
||||
(profilesData || []).forEach(p => { names[p.id] = p.username; });
|
||||
setOperatorNames(names);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 按采购商分组
|
||||
const grouped: Record<string, PlanGroup> = {};
|
||||
washingPlans.forEach(plan => {
|
||||
washingPlans.forEach((plan: any) => {
|
||||
const purchaserId = plan.company_id;
|
||||
const purchaserName = (plan.company as any)?.name || '未知采购商';
|
||||
const purchaserName = plan.purchaser_name || plan.company?.name || '未知采购商';
|
||||
|
||||
if (!grouped[purchaserId]) {
|
||||
grouped[purchaserId] = {
|
||||
purchaserId,
|
||||
purchaserName,
|
||||
plans: []
|
||||
};
|
||||
grouped[purchaserId] = { purchaserId, purchaserName, plans: [] };
|
||||
}
|
||||
|
||||
const processSteps = (stepsData || [])
|
||||
const processSteps = stepsData
|
||||
.filter(s => s.washing_plan_id === plan.id)
|
||||
.sort((a, b) => {
|
||||
const order = ['confirm', 'start_washing', 'complete'];
|
||||
@ -133,9 +110,9 @@ export function WashingPlanOverview() {
|
||||
|
||||
grouped[purchaserId].plans.push({
|
||||
...plan,
|
||||
product_name: (plan.product as any)?.product_name,
|
||||
product_color: (plan.product as any)?.color,
|
||||
fabric_code: (plan.product as any)?.fabric_code,
|
||||
product_name: plan.product_name || plan.product?.product_name,
|
||||
product_color: plan.product_color || plan.product?.color,
|
||||
fabric_code: plan.fabric_code || plan.product?.fabric_code,
|
||||
purchaser_name: purchaserName,
|
||||
processSteps
|
||||
});
|
||||
@ -146,6 +123,9 @@ export function WashingPlanOverview() {
|
||||
if (groupList.length > 0) {
|
||||
setExpandedGroups(new Set([groupList[0].purchaserId]));
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, { userMessage: '获取水洗计划失败' });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@ -201,41 +181,25 @@ export function WashingPlanOverview() {
|
||||
// 确认流程步骤
|
||||
const handleConfirmStep = async (stepId: string, stepType: string) => {
|
||||
setConfirming(stepId);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
// 更新步骤状态
|
||||
await supabase
|
||||
.from('washing_process_steps')
|
||||
.update({
|
||||
status: WASHING_PLAN_STATUS.COMPLETED,
|
||||
timestamp: now,
|
||||
operator_id: auth.user?.id
|
||||
})
|
||||
.eq('id', stepId);
|
||||
const plan = groups.flatMap(g => g.plans).find(
|
||||
p => p.processSteps.some(s => s.id === stepId)
|
||||
);
|
||||
if (!plan) { setConfirming(null); return; }
|
||||
|
||||
// 获取计划ID
|
||||
const { data: step } = await supabase
|
||||
.from('washing_process_steps')
|
||||
.select('washing_plan_id')
|
||||
.eq('id', stepId)
|
||||
.single();
|
||||
|
||||
if (step) {
|
||||
// 更新计划状态
|
||||
if (stepType === 'confirm') {
|
||||
await supabase
|
||||
.from('washing_plans')
|
||||
.update({ status: WASHING_PLAN_STATUS.IN_PROGRESS })
|
||||
.eq('id', step.washing_plan_id);
|
||||
await washingApi.updatePlanStatus(plan.id, { status: WASHING_PLAN_STATUS.IN_PROGRESS });
|
||||
} else if (stepType === 'complete') {
|
||||
await supabase
|
||||
.from('washing_plans')
|
||||
.update({ status: WASHING_PLAN_STATUS.COMPLETED })
|
||||
.eq('id', step.washing_plan_id);
|
||||
}
|
||||
await washingApi.updatePlanStatus(plan.id, { status: WASHING_PLAN_STATUS.COMPLETED });
|
||||
}
|
||||
|
||||
await http.patch(`/washing/process-steps/${stepId}`, {
|
||||
status: WASHING_PLAN_STATUS.COMPLETED,
|
||||
timestamp: new Date().toISOString(),
|
||||
operator_id: auth.user?.id
|
||||
});
|
||||
|
||||
await fetchPlans();
|
||||
} catch (err) {
|
||||
handleError(err, { userMessage: '确认失败,请重试', showAlert: true });
|
||||
@ -259,29 +223,13 @@ export function WashingPlanOverview() {
|
||||
e.preventDefault();
|
||||
if (!selectedPlan || !auth.user) return;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
// 创建完成记录
|
||||
await supabase.from('washing_plan_completions').insert({
|
||||
company_id: auth.company!.id,
|
||||
washing_plan_id: selectedPlan.id,
|
||||
await washingApi.completePlan(selectedPlan.id, {
|
||||
actual_washed_meters: parseFloat(completeForm.meters),
|
||||
actual_shrinkage_rate: parseFloat(completeForm.shrinkageRate),
|
||||
rolls: parseInt(completeForm.rolls) || 0,
|
||||
washing_date: new Date().toISOString().split('T')[0],
|
||||
completed_by: auth.user.id
|
||||
});
|
||||
|
||||
// 更新计划状态
|
||||
await supabase
|
||||
.from('washing_plans')
|
||||
.update({
|
||||
status: WASHING_PLAN_STATUS.COMPLETED,
|
||||
actual_washed_meters: parseFloat(completeForm.meters),
|
||||
actual_shrinkage_rate: parseFloat(completeForm.shrinkageRate)
|
||||
})
|
||||
.eq('id', selectedPlan.id);
|
||||
} as any);
|
||||
|
||||
setCompleteModalOpen(false);
|
||||
setSelectedPlan(null);
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
/**
|
||||
* 🚨 此文件由 Meoo Cloud 自动生成,请勿手动修改!
|
||||
* This file is auto-generated by Meoo Cloud. Do not edit manually!
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { Database } from './types';
|
||||
|
||||
export function getSupabaseUrl(): string {
|
||||
return `${(window as any).MEOO_CONFIG?.meoo_app_access_url || location.origin}/sb-api`;
|
||||
}
|
||||
|
||||
export const supabaseUrl = getSupabaseUrl();
|
||||
export const supabaseAnonKey = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJhbm9uIiwiaWF0IjoxNzc5MzM2NjUyLCJleHAiOjEzMjg5OTc2NjUyfQ.NQrukIeqa-IEBtFEf8YOsVK4vxbi6kQqIi-eyRsb2pA';
|
||||
|
||||
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
},
|
||||
});
|
||||
@ -3,7 +3,7 @@
|
||||
* 确保财务与核心数据100%正确
|
||||
*/
|
||||
|
||||
import { supabase } from '../supabase/client';
|
||||
import { http } from '../api';
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
@ -150,34 +150,17 @@ export async function checkPlanQuantityConsistency(planId: string): Promise<{
|
||||
inventoryTotal: number;
|
||||
difference: number;
|
||||
}> {
|
||||
const { data: plan, error: planError } = await supabase
|
||||
.from('production_plans')
|
||||
.select('completed_quantity')
|
||||
.eq('id', planId)
|
||||
.single();
|
||||
const { data } = await http.get<any>(`/textile/plans/${planId}/consistency`);
|
||||
|
||||
if (planError || !plan) {
|
||||
throw new Error(`查询计划失败: ${planError?.message}`);
|
||||
if (!data) {
|
||||
throw new Error(`查询计划一致性失败`);
|
||||
}
|
||||
|
||||
const { data: inventorySum, error: sumError } = await supabase
|
||||
.from('inventory_records')
|
||||
.select('quantity')
|
||||
.eq('plan_id', planId);
|
||||
|
||||
if (sumError) {
|
||||
throw new Error(`查询入库记录失败: ${sumError.message}`);
|
||||
}
|
||||
|
||||
const inventoryTotal = inventorySum?.reduce((sum, r) => sum + (r.quantity || 0), 0) || 0;
|
||||
const difference = plan.completed_quantity - inventoryTotal;
|
||||
const tolerance = 0.01;
|
||||
|
||||
return {
|
||||
isConsistent: Math.abs(difference) <= tolerance,
|
||||
completedQuantity: plan.completed_quantity,
|
||||
inventoryTotal,
|
||||
difference,
|
||||
isConsistent: data.is_consistent ?? Math.abs(data.difference) <= 0.01,
|
||||
completedQuantity: data.completed_quantity ?? 0,
|
||||
inventoryTotal: data.inventory_total ?? 0,
|
||||
difference: data.difference ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -197,14 +180,7 @@ export async function runFullDataIntegrityCheck(): Promise<DataIntegrityReport>
|
||||
};
|
||||
|
||||
try {
|
||||
// 调用数据库函数进行检查
|
||||
const { data, error } = await supabase.rpc('run_data_integrity_check');
|
||||
|
||||
if (error) {
|
||||
console.error('数据完整性检查失败:', error);
|
||||
report.overallStatus = 'FAIL';
|
||||
return report;
|
||||
}
|
||||
const { data } = await http.post<any[]>('/common/data-integrity-check');
|
||||
|
||||
if (data && Array.isArray(data)) {
|
||||
for (const check of data) {
|
||||
@ -264,7 +240,6 @@ export async function runFullDataIntegrityCheck(): Promise<DataIntegrityReport>
|
||||
report.overallStatus = report.errorRate <= 1 ? 'PASS' : 'FAIL';
|
||||
|
||||
} catch (err) {
|
||||
console.error('数据完整性检查异常:', err);
|
||||
report.overallStatus = 'FAIL';
|
||||
}
|
||||
|
||||
@ -279,51 +254,17 @@ export async function runFullDataIntegrityCheck(): Promise<DataIntegrityReport>
|
||||
* 获取最近的审计日志
|
||||
*/
|
||||
export async function getRecentAuditLogs(limit: number = 100) {
|
||||
const { data, error } = await supabase
|
||||
.from('data_audit_logs')
|
||||
.select('*')
|
||||
.order('changed_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`查询审计日志失败: ${error.message}`);
|
||||
}
|
||||
|
||||
const { data } = await http.get<any[]>('/common/audit-logs', { limit });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取失败的验证记录
|
||||
*/
|
||||
export async function getFailedValidations(limit: number = 50) {
|
||||
const { data, error } = await supabase
|
||||
.from('data_audit_logs')
|
||||
.select('*')
|
||||
.eq('validation_passed', false)
|
||||
.order('changed_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`查询失败记录失败: ${error.message}`);
|
||||
}
|
||||
|
||||
const { data } = await http.get<any[]>('/common/audit-logs', { validation_passed: 'false', limit });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误率统计
|
||||
*/
|
||||
export async function getErrorRateStats(days: number = 30) {
|
||||
const { data, error } = await supabase
|
||||
.from('v_error_rate_monitor')
|
||||
.select('*')
|
||||
.gte('check_date', new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString())
|
||||
.order('check_date', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
throw new Error(`查询错误率统计失败: ${error.message}`);
|
||||
}
|
||||
|
||||
const { data } = await http.get<any[]>('/common/error-rate-stats', { days });
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -343,35 +284,22 @@ export async function safeInsertInventoryRecord(record: {
|
||||
operator_id: string;
|
||||
price_per_meter?: number;
|
||||
}) {
|
||||
// 第一层:前端校验
|
||||
const validation = validateInventoryRecord(record);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`数据校验失败: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// 第二层:数据库写入(触发器会进行二次校验)
|
||||
const { data, error } = await supabase
|
||||
.from('inventory_records')
|
||||
.insert(record)
|
||||
.select();
|
||||
const { data } = await http.post<any>(`/textile/plans/${record.plan_id}/inventory`, record);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`写入失败: ${error.message}`);
|
||||
if (!data) {
|
||||
throw new Error('写入失败');
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
throw new Error('写入失败:可能被 RLS 策略拦截');
|
||||
}
|
||||
|
||||
// 第三层:写入后验证
|
||||
const consistencyCheck = await checkPlanQuantityConsistency(record.plan_id);
|
||||
if (!consistencyCheck.isConsistent) {
|
||||
console.warn(
|
||||
`警告:入库后数据不一致,计划 ${record.plan_id} 完成数量 ${consistencyCheck.completedQuantity},入库总和 ${consistencyCheck.inventoryTotal}`
|
||||
);
|
||||
}
|
||||
|
||||
return data[0];
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -386,25 +314,16 @@ export async function safeInsertPayment(payment: {
|
||||
price_per_meter: number;
|
||||
status: 'pending' | 'completed';
|
||||
}) {
|
||||
// 第一层:前端校验
|
||||
const validation = validatePayment(payment);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`数据校验失败: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// 第二层:数据库写入
|
||||
const { data, error } = await supabase
|
||||
.from('payments')
|
||||
.insert(payment)
|
||||
.select();
|
||||
const { data } = await http.post<any>('/textile/payments', payment);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`写入失败: ${error.message}`);
|
||||
if (!data) {
|
||||
throw new Error('写入失败');
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
throw new Error('写入失败:可能被 RLS 策略拦截');
|
||||
}
|
||||
|
||||
return data[0];
|
||||
return data;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { supabase } from '../supabase/client';
|
||||
import { http } from '../api';
|
||||
import { commonApi } from '../api';
|
||||
|
||||
export type NotificationType = 'step_completed' | 'plan_created' | 'plan_updated' | 'inventory_in' | 'payment_pending' | 'system';
|
||||
|
||||
@ -12,13 +13,9 @@ interface NotificationData {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送通知给指定用户
|
||||
*/
|
||||
export async function sendNotification(data: NotificationData): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('notifications')
|
||||
.insert({
|
||||
try {
|
||||
await http.post('/common/notifications', {
|
||||
recipient_id: data.recipient_id,
|
||||
sender_id: data.sender_id,
|
||||
sender_company_id: data.sender_company_id,
|
||||
@ -27,72 +24,31 @@ export async function sendNotification(data: NotificationData): Promise<void> {
|
||||
title: data.title,
|
||||
content: data.content
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('发送通知失败:', error);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取计划的采购商用户ID
|
||||
*/
|
||||
export async function getPlanPurchaserUserId(planId: string): Promise<string | null> {
|
||||
const { data: plan } = await supabase
|
||||
.from('production_plans')
|
||||
.select('purchaser_id, created_by')
|
||||
.eq('id', planId)
|
||||
.maybeSingle();
|
||||
|
||||
try {
|
||||
const { data: plan } = await http.get<any>(`/common/plans/${planId}/purchaser`);
|
||||
if (!plan) return null;
|
||||
|
||||
// 优先返回创建者,如果没有则返回公司的主账号
|
||||
if (plan.created_by) {
|
||||
return plan.created_by;
|
||||
return plan.user_id || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取公司的主账号
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.eq('company_id', plan.purchaser_id)
|
||||
.eq('is_master', true)
|
||||
.maybeSingle();
|
||||
|
||||
return profile?.id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取计划的关联工厂用户ID列表
|
||||
*/
|
||||
export async function getPlanFactoryUserIds(planId: string, factoryType?: 'textile' | 'washing'): Promise<string[]> {
|
||||
let query = supabase
|
||||
.from('plan_factories')
|
||||
.select('factory_id')
|
||||
.eq('plan_id', planId);
|
||||
|
||||
if (factoryType) {
|
||||
query = query.eq('factory_type', factoryType);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (factoryType) params.factory_type = factoryType;
|
||||
const { data } = await http.get<any[]>(`/common/plans/${planId}/factory-users`, params);
|
||||
return (data || []).map(p => p.id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { data: factories } = await query;
|
||||
|
||||
if (!factories || factories.length === 0) return [];
|
||||
|
||||
const factoryIds = factories.map(f => f.factory_id);
|
||||
|
||||
// 获取这些工厂的主账号用户ID
|
||||
const { data: profiles } = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.in('company_id', factoryIds)
|
||||
.eq('is_master', true);
|
||||
|
||||
return (profiles || []).map(p => p.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送流程节点完成通知给采购商
|
||||
*/
|
||||
export async function notifyStepCompleted(
|
||||
planId: string,
|
||||
stepName: string,
|
||||
@ -100,48 +56,17 @@ export async function notifyStepCompleted(
|
||||
senderUserId: string,
|
||||
details?: { quantity?: number; rolls?: number }
|
||||
): Promise<void> {
|
||||
const purchaserId = await getPlanPurchaserUserId(planId);
|
||||
if (!purchaserId) return;
|
||||
|
||||
const { data: plan } = await supabase
|
||||
.from('production_plans')
|
||||
.select('plan_code, product_name, color, fabric_code, color_code')
|
||||
.eq('id', planId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!plan) return;
|
||||
|
||||
const { data: senderCompany } = await supabase
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', senderCompanyId)
|
||||
.maybeSingle();
|
||||
|
||||
let title = `生产节点完成: ${stepName}`;
|
||||
let content = `计划 ${plan.plan_code} (${plan.fabric_code}-${plan.color_code}) 的"${stepName}"节点已由 ${senderCompany?.name || '工厂'} 确认完成`;
|
||||
|
||||
if (stepName === '坯布生产' && details?.quantity) {
|
||||
title = `坯布生产: ${plan.fabric_code}`;
|
||||
content = `${senderCompany?.name || '工厂'} 完成入库 ${details.quantity}米`;
|
||||
if (details.rolls) {
|
||||
content += ` (${details.rolls}匹)`;
|
||||
}
|
||||
}
|
||||
|
||||
await sendNotification({
|
||||
recipient_id: purchaserId,
|
||||
sender_id: senderUserId,
|
||||
try {
|
||||
await http.post(`/common/plans/${planId}/notify/step-completed`, {
|
||||
step_name: stepName,
|
||||
sender_company_id: senderCompanyId,
|
||||
plan_id: planId,
|
||||
type: 'step_completed',
|
||||
title,
|
||||
content
|
||||
sender_user_id: senderUserId,
|
||||
details
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送入库通知给采购商
|
||||
*/
|
||||
export async function notifyInventoryIn(
|
||||
planId: string,
|
||||
quantity: number,
|
||||
@ -152,80 +77,34 @@ export async function notifyInventoryIn(
|
||||
await notifyStepCompleted(planId, '坯布入库', senderCompanyId, senderUserId, { quantity, rolls });
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送计划创建通知给关联工厂
|
||||
*/
|
||||
export async function notifyPlanCreated(
|
||||
planId: string,
|
||||
factoryIds: string[],
|
||||
senderCompanyId: string,
|
||||
senderUserId: string
|
||||
): Promise<void> {
|
||||
const { data: plan } = await supabase
|
||||
.from('production_plans')
|
||||
.select('plan_code, product_name, color, fabric_code, color_code, target_quantity')
|
||||
.eq('id', planId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!plan) return;
|
||||
|
||||
const { data: senderCompany } = await supabase
|
||||
.from('companies')
|
||||
.select('name')
|
||||
.eq('id', senderCompanyId)
|
||||
.maybeSingle();
|
||||
|
||||
// 获取工厂的主账号用户ID
|
||||
const { data: profiles } = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.in('company_id', factoryIds)
|
||||
.eq('is_master', true);
|
||||
|
||||
const notifications = (profiles || []).map(profile =>
|
||||
sendNotification({
|
||||
recipient_id: profile.id,
|
||||
sender_id: senderUserId,
|
||||
try {
|
||||
await http.post(`/common/plans/${planId}/notify/created`, {
|
||||
factory_ids: factoryIds,
|
||||
sender_company_id: senderCompanyId,
|
||||
plan_id: planId,
|
||||
type: 'plan_created',
|
||||
title: `新计划关联: ${plan.fabric_code}`,
|
||||
content: `${senderCompany?.name || '采购商'} 将您添加为计划 ${plan.plan_code} (${plan.fabric_code}-${plan.color_code}) 的纺织厂,计划产量 ${plan.target_quantity}米`
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(notifications);
|
||||
sender_user_id: senderUserId
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送计划状态更新通知
|
||||
*/
|
||||
export async function notifyPlanUpdated(
|
||||
planId: string,
|
||||
status: string,
|
||||
senderCompanyId: string,
|
||||
senderUserId: string
|
||||
): Promise<void> {
|
||||
const purchaserId = await getPlanPurchaserUserId(planId);
|
||||
if (!purchaserId) return;
|
||||
|
||||
const { data: plan } = await supabase
|
||||
.from('production_plans')
|
||||
.select('plan_code, fabric_code, color_code')
|
||||
.eq('id', planId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!plan) return;
|
||||
|
||||
const statusText = status === 'completed' ? '已完成' : status === 'producing' ? '生产中' : '待确定';
|
||||
|
||||
await sendNotification({
|
||||
recipient_id: purchaserId,
|
||||
sender_id: senderUserId,
|
||||
try {
|
||||
await http.post(`/common/plans/${planId}/notify/updated`, {
|
||||
status,
|
||||
sender_company_id: senderCompanyId,
|
||||
plan_id: planId,
|
||||
type: 'plan_updated',
|
||||
title: `计划状态更新: ${plan.fabric_code}`,
|
||||
content: `计划 ${plan.plan_code} (${plan.fabric_code}-${plan.color_code}) 状态已更新为"${statusText}"`
|
||||
sender_user_id: senderUserId
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,6 +94,14 @@ module.exports = (env, argv) => {
|
||||
allowedHosts: 'all',
|
||||
hot: true,
|
||||
historyApiFallback: true,
|
||||
proxy: [
|
||||
{
|
||||
context: ['/api', '/ws'],
|
||||
target: `http://localhost:${process.env.GATEWAY_PORT || '8080'}`,
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
@ -102,8 +110,6 @@ module.exports = (env, argv) => {
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
|
||||
'process.env.VITE_SUPABASE_URL': JSON.stringify(process.env.VITE_SUPABASE_URL || ''),
|
||||
'process.env.VITE_SUPABASE_ANON_KEY': JSON.stringify(process.env.VITE_SUPABASE_ANON_KEY || ''),
|
||||
'__APP_ENV__': JSON.stringify(isDev ? 'development' : 'production'),
|
||||
}),
|
||||
],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user